From 3d271f72ea5ee114c5771802b1fda12cb8b00ebf Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 00:53:13 +0200 Subject: [PATCH 1/4] redis: say whether it still holds a consumer's ACL user The server keeps ACL users in memory only, so a restart forgets every consumer while the provisioner keeps running (hq issue 120). holds() checks ACL GETUSER for the user, enabled, with the mesh's password, so the harness makes a forgotten user again. Needs mesh-sdk 0.1.1. --- modules/redis/client.ts | 23 ++++++++++++++++++++++- modules/redis/package.json | 2 +- modules/redis/provisioner/index.ts | 7 +++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/modules/redis/client.ts b/modules/redis/client.ts index 93a355d..3993842 100644 --- a/modules/redis/client.ts +++ b/modules/redis/client.ts @@ -8,7 +8,7 @@ // order requests were sent, which is what the queue below relies on. import { createConnection, type Socket } from "node:net"; -import { randomBytes } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; /** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */ @@ -113,6 +113,27 @@ export class RedisClient { await this.command("ACL", "DELUSER", username); } + /** + * Whether an ACL user exists, is enabled, and accepts exactly this password. Read-only: it asks + * `ACL GETUSER`, which answers nil for an unknown user and otherwise a flat list of fields, among + * them `flags` and `passwords`, the latter as SHA-256 hex. This server keeps no ACL file, so its + * users live in memory and a restart forgets them. This is how the provisioner notices + * (novox/hq issue 120). + */ + async holdsAclUser(username: string, password: string): Promise { + const reply = await this.command("ACL", "GETUSER", username); + if (!Array.isArray(reply)) return false; + const field = (name: string): RespValue | undefined => { + const i = reply.indexOf(name); + return i >= 0 ? reply[i + 1] : undefined; + }; + const flags = field("flags"); + const passwords = field("passwords"); + if (!Array.isArray(flags) || !flags.includes("on")) return false; + if (!Array.isArray(passwords)) return false; + return passwords.includes(createHash("sha256").update(password).digest("hex")); + } + close(): void { if (this.socket) { this.socket.destroy(); diff --git a/modules/redis/package.json b/modules/redis/package.json index 7d32bdb..5e76d02 100644 --- a/modules/redis/package.json +++ b/modules/redis/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/redis/provisioner/index.ts b/modules/redis/provisioner/index.ts index c3ea7f7..84aea66 100644 --- a/modules/redis/provisioner/index.ts +++ b/modules/redis/provisioner/index.ts @@ -43,4 +43,11 @@ runProvisioner("redis-cache", { await redis.deleteAclUser(p.as); await announce("module.redis.cache.deprovisioned", { username: p.as }); }, + + // This server keeps its ACL users in memory only, so a restart of it forgets every consumer while + // this provisioner keeps running. Asked every minute, so a forgotten user is made again instead + // of every consumer failing to authenticate in silence (novox/hq issue 120). + async holds(p: Provision): Promise { + return redis.holdsAclUser(p.as, p.password); + }, }); -- 2.54.0 From 0cb0f814b485c533b348559dc762fc5d16bccaea Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 01:09:52 +0200 Subject: [PATCH 2/4] Every credential provider says whether it still holds a consumer holds() for postgres, mssql, mongodb, minio, lavinmq, mosquitto, mailu and gitea, so the harness makes again a login the backend lost (hq issue 120). Each checks the mesh's password as the consumer presents it, or compares it read-only, and returns false only when the backend says the credential is absent or wrong; an unreachable backend throws. --- modules/gitea/client.ts | 23 +++++++++ modules/gitea/package.json | 2 +- modules/gitea/provisioner/index.ts | 5 ++ modules/lavinmq/client.ts | 33 +++++++++++++ modules/lavinmq/package.json | 2 +- modules/lavinmq/provisioner/index.ts | 5 ++ modules/mailu/client.ts | 29 +++++++++++ modules/mailu/package.json | 2 +- modules/mailu/provisioner/index.ts | 5 ++ modules/minio/client.ts | 21 ++++++-- modules/minio/package.json | 2 +- modules/minio/provisioner/index.ts | 6 +++ modules/mongodb/client.ts | 26 ++++++++++ modules/mongodb/package.json | 2 +- modules/mongodb/provisioner/index.ts | 5 ++ modules/mosquitto/client.ts | 66 ++++++++++++++++++++++++++ modules/mosquitto/package.json | 2 +- modules/mosquitto/provisioner/index.ts | 5 ++ modules/mssql/client.ts | 19 ++++++++ modules/mssql/package.json | 2 +- modules/mssql/provisioner/index.ts | 5 ++ modules/postgres/client.ts | 24 ++++++++++ modules/postgres/package.json | 2 +- modules/postgres/provisioner/index.ts | 5 ++ 24 files changed, 286 insertions(+), 12 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 0fcc47e..d510b10 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -433,6 +433,29 @@ export class GiteaAdmin { GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res); } + /** + * Whether a consumer's user logs in with exactly this password and is still a member of the + * package team. Read-only: the password is checked as the consumer presents it, basic auth on the + * API, and membership through the admin API. `false` for a refused login or a missing member; any + * other answer rejects (novox/hq issue 120). + */ + async holdsTeamMember(org: string, team: string, username: string, password: string): Promise { + const me = await fetch(`${this.baseUrl}/api/v1/user`, { + headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") }, + }); + if (me.status === 401 || me.status === 403) return false; + if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`); + const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams`); + if (teams.status === 404) return false; + if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams); + const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team); + if (!found) return false; + const member = await this.request(`/teams/${found.id}/members/${encodeURIComponent(username)}`); + if (member.status === 200 || member.status === 204) return true; + if (member.status === 404) return false; + GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member); + } + /** 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/package.json b/modules/gitea/package.json index 04e8df1..ec33440 100644 --- a/modules/gitea/package.json +++ b/modules/gitea/package.json @@ -9,7 +9,7 @@ "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts index 577487b..f211baa 100644 --- a/modules/gitea/provisioner/index.ts +++ b/modules/gitea/provisioner/index.ts @@ -46,4 +46,9 @@ runProvisioner("package-registry", { async remove(p: { as: string }): Promise { await gitea.deleteUser(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). + async holds(p: Provision): Promise { + return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password); + }, }); diff --git a/modules/lavinmq/client.ts b/modules/lavinmq/client.ts index 04716b2..04a30c4 100644 --- a/modules/lavinmq/client.ts +++ b/modules/lavinmq/client.ts @@ -94,6 +94,39 @@ export class LavinmqClient { await this.api("PUT", `/permissions/${v}/${u}`, { configure: ".*", write: ".*", read: ".*" }); } + /** + * Whether a consumer's user exists with exactly this password and full permissions on its own + * vhost. Read-only: the stored hash is salted SHA-256, the scheme `rabbitHash` writes, so the + * password is checked by hashing it with the stored salt rather than by logging in. `false` when + * the user or its permission is gone or the password differs; an unreachable API rejects + * (novox/hq issue 120). + */ + async holdsConsumer(login: string, password: string): Promise { + const v = encodeURIComponent(login); + const u = encodeURIComponent(login); + const user = await this.getOrNull<{ password_hash?: string; hashing_algorithm?: string }>(`/users/${u}`); + if (!user?.password_hash) return false; + if (user.hashing_algorithm && !/sha256/i.test(user.hashing_algorithm)) { + throw new Error(`lavinmq user ${login} is hashed with ${user.hashing_algorithm}, which this check cannot verify`); + } + const stored = Buffer.from(user.password_hash, "base64"); + if (stored.length < 5 || rabbitHash(password, stored.subarray(0, 4)) !== user.password_hash) return false; + const perm = await this.getOrNull<{ configure?: string; write?: string; read?: string }>(`/permissions/${v}/${u}`); + return perm?.configure === ".*" && perm?.write === ".*" && perm?.read === ".*"; + } + + /** A GET that answers null for a 404 and rejects on anything else that is not 2xx. */ + private async getOrNull(path: string): Promise { + const resp = await fetch(`${this.conn.base}/api${path}`, { + headers: { + Authorization: "Basic " + Buffer.from(`${this.conn.adminUser}:${this.conn.adminPassword}`).toString("base64"), + }, + }); + if (resp.status === 404) return null; + if (!resp.ok) throw new Error(`lavinmq management API GET ${path} -> ${resp.status}: ${await resp.text()}`); + return (await resp.json()) as T; + } + /** Remove a consumer's vhost and user, idempotently. A DELETE of what is already gone is tolerated. */ async removeConsumer(login: string): Promise { const v = encodeURIComponent(login); diff --git a/modules/lavinmq/package.json b/modules/lavinmq/package.json index 1a4b297..9e21bb1 100644 --- a/modules/lavinmq/package.json +++ b/modules/lavinmq/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/lavinmq/provisioner/index.ts b/modules/lavinmq/provisioner/index.ts index f5514bf..7cea27f 100644 --- a/modules/lavinmq/provisioner/index.ts +++ b/modules/lavinmq/provisioner/index.ts @@ -48,4 +48,9 @@ runProvisioner("amqp", { await lavinmq.removeConsumer(p.as); await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: 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). + async holds(p: Provision): Promise { + return lavinmq.holdsConsumer(p.as, p.password); + }, }); diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index 340c70f..e1d1291 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -134,6 +134,35 @@ export class MailuClient { await this.api("DELETE", `/user/${encodeURIComponent(email)}`); } + /** + * Whether a mailbox exists, is enabled, and accepts exactly this password. Read-only. Existence + * from the admin API; the password from `doveadm auth test` in the imap container, which is how + * the mail server itself authenticates, and which exits 77 for a refused login. The password + * reaches doveadm through the exec's environment, never the host's argv. An unreachable API or + * container rejects (novox/hq issue 120). + */ + async holdsUser(email: string, password: string): Promise { + const res = await fetch(`${this.baseUrl}/user/${encodeURIComponent(email)}`, { + headers: { Authorization: this.apiKey, Accept: "application/json" }, + }); + if (res.status === 404) return false; + if (!res.ok) throw new Error(`Mailu API GET /user/${email}: ${res.status} ${await res.text()}`); + const user = (await res.json()) as { enabled?: boolean }; + if (user.enabled === false) return false; + try { + await run( + "docker", + ["exec", "-e", "MESH_USER", "-e", "MESH_PW", this.imapContainer, + "sh", "-c", 'doveadm auth test "$MESH_USER" "$MESH_PW"'], + { env: { ...process.env, MESH_USER: email, MESH_PW: password }, timeout: 30_000 }, + ); + return true; + } catch (err) { + if ((err as { code?: number }).code === 77) return false; + throw err; + } + } + async listAliases(): Promise { const aliases = await this.api("GET", "/alias"); return (aliases ?? []).map((a) => ({ diff --git a/modules/mailu/package.json b/modules/mailu/package.json index 00d231a..14156bd 100644 --- a/modules/mailu/package.json +++ b/modules/mailu/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mailu/provisioner/index.ts b/modules/mailu/provisioner/index.ts index 42ef44d..27ecb37 100644 --- a/modules/mailu/provisioner/index.ts +++ b/modules/mailu/provisioner/index.ts @@ -62,4 +62,9 @@ runProvisioner("smtp", { // named-account consumer is an operator action until the harness carries values here. await mailu.deleteUser(`${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). + async holds(p: Provision): Promise { + return mailu.holdsUser(addressOf(p), p.password); + }, }); diff --git a/modules/minio/client.ts b/modules/minio/client.ts index a3f2ebe..beb751f 100644 --- a/modules/minio/client.ts +++ b/modules/minio/client.ts @@ -125,6 +125,18 @@ export class MinioClient { throw new Error(`minio bucketExists ${bucket}: ${status}`); } + /** + * Whether a consumer's access key, with exactly this secret, reaches its bucket: a HEAD of the + * bucket signed as the consumer, the way it signs. Read-only. `false` when the key is unknown, the + * secret wrong, access denied or the bucket gone; any other answer rejects (novox/hq issue 120). + */ + async canReachAs(bucket: string, accessKey: string, secretKey: string): Promise { + const { status } = await this.request("HEAD", `/${bucket}`, {}, { accessKey, secretKey }); + if (status === 200) return true; + if (status === 403 || status === 404) return false; + throw new Error(`minio HEAD ${bucket} as ${accessKey}: ${status}`); + } + async createBucket(bucket: string): Promise { const { status, text } = await this.request("PUT", `/${bucket}`); // 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail. @@ -251,6 +263,7 @@ export class MinioClient { method: string, path: string, query: Record = {}, + as: { accessKey: string; secretKey: string } = { accessKey: this.rootUser, secretKey: this.rootPassword }, ): Promise<{ status: number; headers: Headers; text: string }> { const { amzDate, dateStamp } = this.stamp(); const host = new URL(this.baseUrl).host; @@ -262,8 +275,8 @@ export class MinioClient { const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n"); const scope = `${dateStamp}/${this.region}/s3/aws4_request`; const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n"); - const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex"); - const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + const signature = hmac(this.signingKey(dateStamp, as.secretKey), stringToSign).toString("hex"); + const authorization = `AWS4-HMAC-SHA256 Credential=${as.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`; const res = await fetch(url, { @@ -275,8 +288,8 @@ export class MinioClient { return { status: res.status, headers: res.headers, text }; } - private signingKey(dateStamp: string): Buffer { - const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp); + private signingKey(dateStamp: string, secretKey: string = this.rootPassword): Buffer { + const kDate = hmac(`AWS4${secretKey}`, dateStamp); const kRegion = hmac(kDate, this.region); const kService = hmac(kRegion, "s3"); return hmac(kService, "aws4_request"); diff --git a/modules/minio/package.json b/modules/minio/package.json index 9e74934..7441fc6 100644 --- a/modules/minio/package.json +++ b/modules/minio/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts index cc07052..5e15c01 100644 --- a/modules/minio/provisioner/index.ts +++ b/modules/minio/provisioner/index.ts @@ -53,6 +53,12 @@ runProvisioner("s3-bucket", { await announce("module.minio.bucket.removed", { bucket, accessKey: 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). + async holds(p: Provision): Promise { + return minio.canReachAs(bucketFor(p.as), p.as, p.password); + }, }); /** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a diff --git a/modules/mongodb/client.ts b/modules/mongodb/client.ts index ce4f2e3..f0fb4de 100644 --- a/modules/mongodb/client.ts +++ b/modules/mongodb/client.ts @@ -109,6 +109,32 @@ print(EJSON.stringify({ ok: 1 })); await this.evalJs<{ ok: number }>(js); } + /** + * Whether `user` authenticates against `database` with exactly `password` and holds `dbOwner` + * there: checked by connecting as the consumer, the way it connects. Read-only. `false` only on an + * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). + */ + async canAuthenticateAs(database: string, user: string, password: string): Promise { + const uri = + `mongodb://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${this.conn.host}:${this.conn.port}` + + `/${encodeURIComponent(database)}?authSource=${encodeURIComponent(database)}&serverSelectionTimeoutMS=10000`; + let stdout: string; + try { + ({ stdout } = await run( + "mongosh", + [uri, "--quiet", "--eval", + "print(EJSON.stringify(db.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"], + { timeout: 30_000 }, + )); + } catch (err) { + const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`; + if (/Authentication failed|AuthenticationFailed/i.test(text)) return false; + throw err; + } + const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[]; + return roles.some((r) => r.role === "dbOwner" && r.db === database); + } + /** 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. */ async dropDatabaseAndUser(database: string, user: string): Promise { diff --git a/modules/mongodb/package.json b/modules/mongodb/package.json index 4195793..479e7ea 100644 --- a/modules/mongodb/package.json +++ b/modules/mongodb/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mongodb/provisioner/index.ts b/modules/mongodb/provisioner/index.ts index b42b2fc..e532b62 100644 --- a/modules/mongodb/provisioner/index.ts +++ b/modules/mongodb/provisioner/index.ts @@ -45,4 +45,9 @@ runProvisioner("mongodb-database", { await mongo.dropDatabaseAndUser(p.as, p.as); await announce("module.mongodb.database.deprovisioned", { database: 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). + async holds(p: Provision): Promise { + return mongo.canAuthenticateAs(p.as, p.as, p.password); + }, }); diff --git a/modules/mosquitto/client.ts b/modules/mosquitto/client.ts index f1a7877..08044ba 100644 --- a/modules/mosquitto/client.ts +++ b/modules/mosquitto/client.ts @@ -15,6 +15,7 @@ // The one cost dynsec carries is the bootstrap file; see initBootstrapFile() and the module README. import { randomBytes } from "node:crypto"; +import { connect as tcpConnect } from "node:net"; import { readFileSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -163,6 +164,19 @@ export class MosquittoClient { } } + /** + * Whether a consumer's client accepts exactly this password and still carries its own role. + * Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it, + * and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised. + * Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120). + */ + async holdsClient(username: string, password: string): Promise { + const code = await mqttConnack(this.conn.host, this.conn.port, username, password); + if (code === 4 || code === 5) return false; + if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`); + return this.clientHasRole(username, username); + } + /** Remove a client and the per-client role created for it, idempotently. */ async deleteScopedClient(username: string): Promise { await ignoreMissing(this.ctl("deleteClient", username)); @@ -249,3 +263,55 @@ function readSecretFile(path: string | undefined): string | undefined { return undefined; } } + +/** + * Connect once over MQTT 3.1.1 with a username and password, return the broker's CONNACK return code, + * and disconnect. A clean session under a throwaway client id, so no consumer session is taken over. + */ +function mqttConnack(host: string, port: number, username: string, password: string): Promise { + const str = (v: string): Buffer => { + const b = Buffer.from(v, "utf8"); + const len = Buffer.alloc(2); + len.writeUInt16BE(b.length); + return Buffer.concat([len, b]); + }; + const variable = Buffer.concat([str("MQTT"), Buffer.from([4, 0xc2, 0, 10])]); // level 4; user+pass+clean; keepalive 10s + const payload = Buffer.concat([str(`mesh-holds-${randomBytes(6).toString("hex")}`), str(username), str(password)]); + let remaining = variable.length + payload.length; + const lenBytes: number[] = []; + do { + let byte = remaining % 128; + remaining = Math.floor(remaining / 128); + if (remaining > 0) byte |= 0x80; + lenBytes.push(byte); + } while (remaining > 0); + const packet = Buffer.concat([Buffer.from([0x10, ...lenBytes]), variable, payload]); + + return new Promise((resolve, reject) => { + const socket = tcpConnect({ host, port }); + let buf = Buffer.alloc(0); + const timer = setTimeout(() => { + socket.destroy(); + reject(new Error(`no CONNACK from ${host}:${port} within 10s`)); + }, 10_000); + socket.on("connect", () => socket.write(packet)); + socket.on("data", (chunk) => { + buf = Buffer.concat([buf, chunk]); + if (buf.length < 4) return; + clearTimeout(timer); + if (buf[0] !== 0x20) { + socket.destroy(); + reject(new Error(`unexpected MQTT packet 0x${buf[0].toString(16)} instead of CONNACK`)); + return; + } + const code = buf[3]; + if (code === 0) socket.end(Buffer.from([0xe0, 0])); // DISCONNECT + else socket.destroy(); + resolve(code); + }); + socket.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} diff --git a/modules/mosquitto/package.json b/modules/mosquitto/package.json index 6f33f27..156253d 100644 --- a/modules/mosquitto/package.json +++ b/modules/mosquitto/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mosquitto/provisioner/index.ts b/modules/mosquitto/provisioner/index.ts index 39dc471..60f9ed6 100644 --- a/modules/mosquitto/provisioner/index.ts +++ b/modules/mosquitto/provisioner/index.ts @@ -43,4 +43,9 @@ runProvisioner("mqtt-topic", { await mosquitto.deleteScopedClient(p.as); await announce("module.mosquitto.topic.deprovisioned", { username: 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). + async holds(p: Provision): Promise { + return mosquitto.holdsClient(p.as, p.password); + }, }); diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index 4b1a89a..6a0ac07 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -142,6 +142,25 @@ export class MssqlClient { await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database); } + /** + * Whether `login` exists, is enabled, has exactly `password`, and is a db_owner user of + * `database`. Read-only: the password is compared with PWDCOMPARE against the stored hash, so + * nothing logs in and no failed-login is recorded (novox/hq issue 120). + */ + async holdsLogin(database: string, login: string, password: string): Promise { + const server = await this.query( + `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` + + `AND is_disabled = 0 AND PWDCOMPARE(${literal(password)}, password_hash) = 1) ` + + `AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`, + ); + if (Number(server[0]?.ok) !== 1) return false; + const owner = await this.query( + `SELECT CAST(IS_ROLEMEMBER('db_owner', ${literal(login)}) AS int) AS ok`, + database, + ); + return Number(owner[0]?.ok) === 1; + } + /** Drop a database and its login, idempotently, after evicting live connections. */ async dropDatabaseAndLogin(database: string, login: string): Promise { const dbs = await this.query( diff --git a/modules/mssql/package.json b/modules/mssql/package.json index b0b97f6..31eeb79 100644 --- a/modules/mssql/package.json +++ b/modules/mssql/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mssql/provisioner/index.ts b/modules/mssql/provisioner/index.ts index e12e8b0..16d0907 100644 --- a/modules/mssql/provisioner/index.ts +++ b/modules/mssql/provisioner/index.ts @@ -44,4 +44,9 @@ runProvisioner("mssql-database", { await mssql.dropDatabaseAndLogin(p.as, p.as); await announce("module.mssql.database.deprovisioned", { database: 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). + async holds(p: Provision): Promise { + return mssql.holdsLogin(p.as, p.as, p.password); + }, }); diff --git a/modules/postgres/client.ts b/modules/postgres/client.ts index fa60b1b..f16af8b 100644 --- a/modules/postgres/client.ts +++ b/modules/postgres/client.ts @@ -99,6 +99,30 @@ export class PostgresClient { await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`); } + /** + * Whether `role` can log in to `database` with exactly `password`: the consumer's own view of its + * credential, checked by connecting as it. Read-only. `false` only when the server says so (the + * role, the password or the database is wrong or gone); an unreachable server rejects instead, + * because being unable to ask is not evidence of loss (novox/hq issue 120). + */ + async canConnectAs(database: string, role: string, password: string): Promise { + try { + await run( + "psql", + ["-h", this.conn.host, "-p", String(this.conn.port), "-U", role, "-d", database, + "-v", "ON_ERROR_STOP=1", "--no-psqlrc", "-tAc", "SELECT 1"], + { env: { ...process.env, PGPASSWORD: password, PGCONNECT_TIMEOUT: "10" }, timeout: 20_000 }, + ); + return true; + } catch (err) { + const text = `${(err as { stderr?: string }).stderr ?? ""}`; + if (/password authentication failed|role ".*" does not exist|database ".*" does not exist|not permitted to log in|permission denied for database/i.test(text)) { + return false; + } + throw err; + } + } + /** Drop a database and its owning role, idempotently, after evicting live connections. */ async dropDatabaseAndRole(database: string, role: string): Promise { await this.query( diff --git a/modules/postgres/package.json b/modules/postgres/package.json index a348964..1256cb6 100644 --- a/modules/postgres/package.json +++ b/modules/postgres/package.json @@ -5,7 +5,7 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.1" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/postgres/provisioner/index.ts b/modules/postgres/provisioner/index.ts index 16bd09e..825cd98 100644 --- a/modules/postgres/provisioner/index.ts +++ b/modules/postgres/provisioner/index.ts @@ -45,4 +45,9 @@ runProvisioner("postgres-database", { await postgres.dropDatabaseAndRole(p.as, p.as); await announce("module.postgres.database.deprovisioned", { database: 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). + async holds(p: Provision): Promise { + return postgres.canConnectAs(p.as, p.as, p.password); + }, }); -- 2.54.0 From 6fd93afc6c9f21d38277f1fcedab9d7d95cec181 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 01:24:32 +0200 Subject: [PATCH 3/4] Review fixes: holds and create agree, and no password leaves a check create re-enables what holds refuses (mssql login, mosquitto client, mailu mailbox, gitea user) and clears an expired postgres password, so no disabled account loops. mssql and mongodb checks take the password from the environment, never argv; mosquitto_ctrl failures no longer repeat -P. mosquitto reads 'could not ask' as an error, not absence. mailu checks existence and enabled only: its imap passdb cannot verify a password. mssql checks the user's SID; gitea pages teams at 50. --- modules/gitea/client.ts | 8 ++++--- modules/mailu/client.ts | 32 ++++++++++----------------- modules/mailu/provisioner/index.ts | 2 +- modules/mongodb/client.ts | 22 ++++++++++--------- modules/mosquitto/client.ts | 33 ++++++++++++++++++++++++---- modules/mssql/client.ts | 35 +++++++++++++++++++++++++----- modules/postgres/client.ts | 6 +++-- 7 files changed, 92 insertions(+), 46 deletions(-) diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index d510b10..06cbb7d 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -390,7 +390,7 @@ export class GiteaAdmin { } private async findTeam(org: string, team: string): Promise { - const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`); + const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`); if (res.status !== 200) return null; const match = (res.body as any[] | null)?.find((t) => t?.name === team); return match ? Number(match.id) : null; @@ -414,7 +414,9 @@ export class GiteaAdmin { const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, { method: "PATCH", // login_name is required by the admin edit endpoint; for a local user it is the username. - body: JSON.stringify({ login_name: username, password, must_change_password: false }), + // active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong + // password, so the provisioner's check reports it lost; applying again must undo both. + body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }), }); if (patch.status === 200) return; GiteaAdmin.fail(`/admin/users/${username}`, patch); @@ -445,7 +447,7 @@ export class GiteaAdmin { }); if (me.status === 401 || me.status === 403) return false; if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`); - const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams`); + const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`); if (teams.status === 404) return false; if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams); const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team); diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index e1d1291..08c54b4 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -127,7 +127,9 @@ export class MailuClient { } async changePassword(email: string, password: string): Promise { - await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password }); + // enabled: a disabled mailbox is what the provisioner's check reports as lost, so applying the + // mesh's password again also enables it; otherwise the two would disagree for ever. + await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true }); } async deleteUser(email: string): Promise { @@ -135,34 +137,24 @@ export class MailuClient { } /** - * Whether a mailbox exists, is enabled, and accepts exactly this password. Read-only. Existence - * from the admin API; the password from `doveadm auth test` in the imap container, which is how - * the mail server itself authenticates, and which exits 77 for a refused login. The password - * reaches doveadm through the exec's environment, never the host's argv. An unreachable API or - * container rejects (novox/hq issue 120). + * Whether a mailbox exists and is enabled. Read-only, through the admin API. + * + * **The password is not checked.** Mailu authenticates in its admin service, behind the front; + * the imap server's own password database accepts any password from Mailu's subnet, so asking it + * (`doveadm auth test`) proves nothing, or refuses everyone. A lost or disabled mailbox is caught; + * a password changed by hand is not (novox/hq issue 120). */ - async holdsUser(email: string, password: string): Promise { + async holdsUser(email: string): Promise { const res = await fetch(`${this.baseUrl}/user/${encodeURIComponent(email)}`, { headers: { Authorization: this.apiKey, Accept: "application/json" }, }); if (res.status === 404) return false; if (!res.ok) throw new Error(`Mailu API GET /user/${email}: ${res.status} ${await res.text()}`); const user = (await res.json()) as { enabled?: boolean }; - if (user.enabled === false) return false; - try { - await run( - "docker", - ["exec", "-e", "MESH_USER", "-e", "MESH_PW", this.imapContainer, - "sh", "-c", 'doveadm auth test "$MESH_USER" "$MESH_PW"'], - { env: { ...process.env, MESH_USER: email, MESH_PW: password }, timeout: 30_000 }, - ); - return true; - } catch (err) { - if ((err as { code?: number }).code === 77) return false; - throw err; - } + return user.enabled !== false; } + async listAliases(): Promise { const aliases = await this.api("GET", "/alias"); return (aliases ?? []).map((a) => ({ diff --git a/modules/mailu/provisioner/index.ts b/modules/mailu/provisioner/index.ts index 27ecb37..41232bb 100644 --- a/modules/mailu/provisioner/index.ts +++ b/modules/mailu/provisioner/index.ts @@ -65,6 +65,6 @@ runProvisioner("smtp", { // 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). async holds(p: Provision): Promise { - return mailu.holdsUser(addressOf(p), p.password); + return mailu.holdsUser(addressOf(p)); }, }); diff --git a/modules/mongodb/client.ts b/modules/mongodb/client.ts index f0fb4de..ae8c666 100644 --- a/modules/mongodb/client.ts +++ b/modules/mongodb/client.ts @@ -115,21 +115,23 @@ print(EJSON.stringify({ ok: 1 })); * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). */ async canAuthenticateAs(database: string, user: string, password: string): Promise { - const uri = - `mongodb://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${this.conn.host}:${this.conn.port}` + - `/${encodeURIComponent(database)}?authSource=${encodeURIComponent(database)}&serverSelectionTimeoutMS=10000`; + // Connected without credentials, then authenticated inside the eval from the environment, so + // the consumer's password is neither on argv nor in the message of a failed command. + const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`; + const js = + "const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" + + "t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" + + "print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"; let stdout: string; try { - ({ stdout } = await run( - "mongosh", - [uri, "--quiet", "--eval", - "print(EJSON.stringify(db.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"], - { timeout: 30_000 }, - )); + ({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], { + env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password }, + timeout: 30_000, + })); } catch (err) { const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`; if (/Authentication failed|AuthenticationFailed/i.test(text)) return false; - throw err; + throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`); } const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[]; return roles.some((r) => r.role === "dbOwner" && r.db === database); diff --git a/modules/mosquitto/client.ts b/modules/mosquitto/client.ts index 08044ba..d6fb10e 100644 --- a/modules/mosquitto/client.ts +++ b/modules/mosquitto/client.ts @@ -88,9 +88,20 @@ export class MosquittoClient { "-u", this.conn.adminUser, "-P", this.conn.adminPassword, ]; - const { stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], { - maxBuffer: 16 << 20, - }); + let stdout: string; + let stderr: string; + try { + ({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], { + maxBuffer: 16 << 20, + timeout: 30_000, + })); + } catch (err) { + // A failed run's message repeats its argv, the admin password (-P) included; say what failed + // without it. + const e = err as { code?: unknown; signal?: unknown; stderr?: string; stdout?: string }; + const detail = `${e.stderr ?? ""}${e.stdout ?? ""}`.trim().slice(0, 500); + throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} could not run (${e.code ?? e.signal ?? "error"}): ${detail}`); + } const failure = ctlError(`${stdout}\n${stderr}`); if (failure) { throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} failed: ${failure}`); @@ -141,6 +152,11 @@ export class MosquittoClient { if (await this.clientExists(username)) { await this.ctl("setClientPassword", username, password); + // A disabled client is refused like a wrong password, so the check the provisioner runs + // reports it lost; applying again must enable it, or the two would disagree for ever. + if (/Disabled:\s*true/i.test(await this.ctl("getClient", username))) { + await this.ctl("enableClient", username); + } } else { await this.ctl("createClient", username, "-p", password); } @@ -174,7 +190,16 @@ export class MosquittoClient { const code = await mqttConnack(this.conn.host, this.conn.port, username, password); if (code === 4 || code === 5) return false; if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`); - return this.clientHasRole(username, username); + // The role, asked directly: only "not found" means absent. Any other failure to ask rejects, + // unlike clientHasRole, which reads every failure as "no role". + let out: string; + try { + out = await this.ctl("getClient", username); + } catch (err) { + if (/not\s*found|does not exist|no such/i.test(String(err))) return false; + throw err; + } + return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out); } /** Remove a client and the per-client role created for it, idempotently. */ diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index 6a0ac07..e1802a4 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -75,14 +75,18 @@ export class MssqlClient { * prints (split across output lines for a large result, and reassembled here) is parsed. An * empty result yields no output at all — an empty array. */ - async query(select: string, database = "master"): Promise[]> { + async query( + select: string, + database = "master", + variables: Record = {}, + ): Promise[]> { const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`; - const stdout = await this.sqlcmd(wrapped, database); + const stdout = await this.sqlcmd(wrapped, database, variables); return parseJsonRows(stdout); } /** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */ - private async sqlcmd(sql: string, database: string): Promise { + private async sqlcmd(sql: string, database: string, variables: Record = {}): Promise { // `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long // JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin // cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships @@ -101,7 +105,9 @@ export class MssqlClient { "-W", "-Q", sql, ], - { env: { ...process.env, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 }, + // `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting + // variables: a value that must not appear on argv, or in the message of a failed command. + { env: { ...process.env, ...variables, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 }, ); return stdout; } @@ -121,6 +127,9 @@ export class MssqlClient { ); } else { await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`); + // A disabled login is refused like a wrong password; the check the provisioner runs reports it + // lost, so applying again must enable it or the two would disagree for ever. + await this.exec(`ALTER LOGIN ${ident(login)} ENABLE`); } const dbs = await this.query( @@ -138,6 +147,10 @@ export class MssqlClient { ); if (users.length === 0) { await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database); + } else { + // Re-point an existing user at the login. A database restored from elsewhere keeps its user + // under the old login's SID, orphaned; this maps it back, and is a no-op when it already is. + await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database); } await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database); } @@ -148,14 +161,24 @@ export class MssqlClient { * nothing logs in and no failed-login is recorded (novox/hq issue 120). */ async holdsLogin(database: string, login: string, password: string): Promise { + // The password reaches sqlcmd as a scripting variable from the environment, never inside the + // query text, so it is neither on argv nor in the message of a failed command. It is the mesh's + // minted value, which carries no quote. const server = await this.query( `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` + - `AND is_disabled = 0 AND PWDCOMPARE(${literal(password)}, password_hash) = 1) ` + + `AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` + `AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`, + "master", + { MESHHOLDSPW: password }, ); if (Number(server[0]?.ok) !== 1) return false; + // The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the + // right name and the wrong SID, and cannot be reached through the login. const owner = await this.query( - `SELECT CAST(IS_ROLEMEMBER('db_owner', ${literal(login)}) AS int) AS ok`, + `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.database_principals dp ` + + `JOIN sys.server_principals sp ON dp.sid = sp.sid ` + + `WHERE dp.name = ${literal(login)} AND sp.name = ${literal(login)}) ` + + `AND IS_ROLEMEMBER('db_owner', ${literal(login)}) = 1 THEN 1 ELSE 0 END AS int) AS ok`, database, ); return Number(owner[0]?.ok) === 1; diff --git a/modules/postgres/client.ts b/modules/postgres/client.ts index f16af8b..ee4d6a9 100644 --- a/modules/postgres/client.ts +++ b/modules/postgres/client.ts @@ -88,9 +88,11 @@ export class PostgresClient { async createDatabaseAndRole(database: string, role: string, password: string): Promise { const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role)); if (roles.rows.length === 0) { - await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`); + await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`); } else { - await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`); + // VALID UNTIL 'infinity': a password that expired is refused like a wrong one, so the check the + // provisioner runs would report it lost, and only clearing the expiry makes applying it again work. + await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`); } const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database)); if (dbs.rows.length === 0) { -- 2.54.0 From 620b47d309af5b692e410f05937d0ae3a73df50f Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 01:30:15 +0200 Subject: [PATCH 4/4] mssql remaps a user only when orphaned; mailu's operator tool no longer re-enables ALTER USER ... WITH LOGIN runs only when the user's SID is not the login's, so an already-mapped user is left alone. The provisioner enables a mailbox through its own method; the password tool an operator uses keeps changing the password only. --- modules/mailu/client.ts | 12 ++++++++++-- modules/mailu/provisioner/index.ts | 2 +- modules/mssql/client.ts | 14 +++++++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index 08c54b4..4ec4cdb 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -127,8 +127,16 @@ export class MailuClient { } async changePassword(email: string, password: string): Promise { - // enabled: a disabled mailbox is what the provisioner's check reports as lost, so applying the - // mesh's password again also enables it; otherwise the two would disagree for ever. + await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password }); + } + + /** + * Set the mesh's password on a mailbox the mesh provisions, and enable it. A disabled mailbox is + * what the provisioner's check reports as lost, so applying again must enable it, or the two would + * disagree for ever. Separate from changePassword, which an operator's tool uses and which must + * not re-enable a mailbox someone disabled. + */ + async applyProvisioned(email: string, password: string): Promise { await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true }); } diff --git a/modules/mailu/provisioner/index.ts b/modules/mailu/provisioner/index.ts index 41232bb..2b73274 100644 --- a/modules/mailu/provisioner/index.ts +++ b/modules/mailu/provisioner/index.ts @@ -48,7 +48,7 @@ runProvisioner("smtp", { try { await mailu.createUser(email, p.password); } catch { - await mailu.changePassword(email, p.password); + await mailu.applyProvisioned(email, p.password); } }, diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index e1802a4..e3304ac 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -148,9 +148,17 @@ export class MssqlClient { if (users.length === 0) { await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database); } else { - // Re-point an existing user at the login. A database restored from elsewhere keeps its user - // under the old login's SID, orphaned; this maps it back, and is a no-op when it already is. - await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database); + // Re-point an existing user at the login when its SID is not the login's: a database restored + // from elsewhere keeps its user under the old login's SID, orphaned. Only then, so a user that + // is already mapped is left alone. + const orphaned = await this.query( + `SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)} ` + + `AND (sid IS NULL OR sid <> SUSER_SID(${literal(login)}))`, + database, + ); + if (orphaned.length > 0) { + await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database); + } } await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database); } -- 2.54.0