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.
This commit is contained in:
+29
-6
@@ -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<Record<string, unknown>[]> {
|
||||
async query(
|
||||
select: string,
|
||||
database = "master",
|
||||
variables: Record<string, string> = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
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<string> {
|
||||
private async sqlcmd(sql: string, database: string, variables: Record<string, string> = {}): Promise<string> {
|
||||
// `-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<boolean> {
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user