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.
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this
|
|
// module's tools and its provisioner import it, and nothing outside mssql does.
|
|
//
|
|
// SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm
|
|
// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query
|
|
// protocol is more surface than this should carry — so it shells out to the client the mssql
|
|
// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu
|
|
// through doveadm. One boundary, `run()`, and every method is built on it.
|
|
//
|
|
// Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and
|
|
// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's
|
|
// column-aligned text, since SQL Server owns the quoting and typing.
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
|
|
const run = promisify(execFile);
|
|
|
|
export interface QueryResult {
|
|
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
|
|
readonly command: string;
|
|
readonly rows: Record<string, unknown>[];
|
|
}
|
|
|
|
export interface MssqlConn {
|
|
readonly host: string;
|
|
readonly port: number;
|
|
readonly user: string;
|
|
readonly password: string;
|
|
}
|
|
|
|
export class MssqlClient {
|
|
constructor(private readonly conn: MssqlConn) {}
|
|
|
|
/**
|
|
* Build from the module's resolved environment. Reads MESH_MSSQL_* first (the documented
|
|
* names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner
|
|
* container. Throws if it cannot find a host and an admin password.
|
|
*/
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): MssqlClient {
|
|
const url = env.MESH_PROVISION_MSSQL ? safeUrl(env.MESH_PROVISION_MSSQL) : undefined;
|
|
const host = env.MESH_MSSQL_HOST ?? url?.hostname;
|
|
const port = Number(env.MESH_MSSQL_PORT ?? url?.port ?? "1433") || 1433;
|
|
const user = env.MESH_MSSQL_USER ?? url?.username ?? "sa";
|
|
const password = env.MESH_MSSQL_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
|
|
if (!host || !password) {
|
|
throw new Error("mssql host or admin password is not set — mssql's own code cannot reach the server");
|
|
}
|
|
return new MssqlClient({ host, port, user, password });
|
|
}
|
|
|
|
get host(): string {
|
|
return this.conn.host;
|
|
}
|
|
|
|
get port(): number {
|
|
return this.conn.port;
|
|
}
|
|
|
|
/**
|
|
* Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is
|
|
* passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a
|
|
* failed statement an error here rather than a success with a warning, and `-C` trusts the
|
|
* server's self-signed certificate the mssql image ships with.
|
|
*/
|
|
async exec(sql: string, database = "master"): Promise<void> {
|
|
await this.sqlcmd(sql, database);
|
|
}
|
|
|
|
/**
|
|
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
|
|
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd
|
|
* 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",
|
|
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, variables);
|
|
return parseJsonRows(stdout);
|
|
}
|
|
|
|
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
|
|
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
|
|
// with postgres's — the module owns its own code (ADR 0039) and shells out to it.
|
|
const { stdout } = await run(
|
|
"sqlcmd",
|
|
[
|
|
"-S", `${this.conn.host},${this.conn.port}`,
|
|
"-U", this.conn.user,
|
|
"-d", database,
|
|
"-C",
|
|
"-b",
|
|
"-h", "-1",
|
|
"-y", "0",
|
|
"-Y", "0",
|
|
"-W",
|
|
"-Q", sql,
|
|
],
|
|
// `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;
|
|
}
|
|
|
|
/**
|
|
* Create a login and a database it owns (mapped as a db_owner user), idempotently. The login,
|
|
* the database and the user all carry the consumer's minted name, so the consumer owns exactly
|
|
* its own database — a name it cannot learn is a database it cannot reach (ADR 0048).
|
|
*/
|
|
async createDatabaseAndLogin(database: string, login: string, password: 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(
|
|
`CREATE LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}, CHECK_POLICY = OFF`,
|
|
);
|
|
} 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(
|
|
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
|
|
);
|
|
if (dbs.length === 0) {
|
|
// CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation.
|
|
await this.exec(`CREATE DATABASE ${ident(database)}`);
|
|
}
|
|
|
|
// Map the login to a db_owner user inside the database it owns.
|
|
const users = await this.query(
|
|
`SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)}`,
|
|
database,
|
|
);
|
|
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 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);
|
|
}
|
|
|
|
/**
|
|
* 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<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(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(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;
|
|
}
|
|
|
|
/** Drop a database and its login, idempotently, after evicting live connections. */
|
|
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
|
|
const dbs = await this.query(
|
|
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
|
|
);
|
|
if (dbs.length > 0) {
|
|
// SINGLE_USER WITH ROLLBACK IMMEDIATE evicts every other session before the drop.
|
|
await this.exec(`ALTER DATABASE ${ident(database)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE`);
|
|
await this.exec(`DROP DATABASE ${ident(database)}`);
|
|
}
|
|
const logins = await this.query(
|
|
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`,
|
|
);
|
|
if (logins.length > 0) {
|
|
await this.exec(`DROP LOGIN ${ident(login)}`);
|
|
}
|
|
}
|
|
|
|
/** List the user databases (database_id > 4 excludes the system four), with size, for the tool. */
|
|
async listDatabases(): Promise<{ name: string; sizeBytes: number; state: string }[]> {
|
|
const rows = await this.query(
|
|
"SELECT d.name AS name, d.state_desc AS state, " +
|
|
"SUM(CAST(f.size AS bigint)) * 8 * 1024 AS size_bytes " +
|
|
"FROM sys.databases d JOIN sys.master_files f ON d.database_id = f.database_id " +
|
|
"WHERE d.database_id > 4 GROUP BY d.name, d.state_desc ORDER BY d.name",
|
|
);
|
|
return rows.map((r) => ({
|
|
name: String(r.name),
|
|
sizeBytes: Number(r.size_bytes ?? 0),
|
|
state: String(r.state ?? ""),
|
|
}));
|
|
}
|
|
|
|
/** Run a read-only SELECT against a named database, for the mssql_query tool. */
|
|
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
|
|
// The read-only guarantee is a wrapping transaction that is always rolled back: any write the
|
|
// statement attempts is undone. The rows are rendered by FOR JSON inside query().
|
|
const rows = await this.query(
|
|
`BEGIN TRANSACTION;\n${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;\nROLLBACK;`,
|
|
database,
|
|
);
|
|
return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows };
|
|
}
|
|
}
|
|
|
|
/** Generate a URL-safe password. */
|
|
export function generatePassword(): string {
|
|
return randomBytes(24).toString("base64url");
|
|
}
|
|
|
|
/** Quote a T-SQL identifier (square brackets, doubled internal `]`). */
|
|
export function ident(id: string): string {
|
|
return "[" + id.replace(/]/g, "]]") + "]";
|
|
}
|
|
|
|
/** Quote a T-SQL string literal (single quotes, doubled internal quotes). */
|
|
export function literal(val: string): string {
|
|
return "'" + val.replace(/'/g, "''") + "'";
|
|
}
|
|
|
|
/** Strip trailing semicolons and whitespace so FOR JSON can be appended to a caller's SELECT. */
|
|
function stripTrailingSemis(sql: string): string {
|
|
return sql.replace(/[\s;]+$/, "");
|
|
}
|
|
|
|
function readSecretFile(path: string | undefined): string | undefined {
|
|
if (!path) return undefined;
|
|
try {
|
|
return readFileSync(path, "utf8").trim();
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function safeUrl(raw: string): URL | undefined {
|
|
try {
|
|
return new URL(raw);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result
|
|
* into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so
|
|
* the document is reassembled by concatenating the non-empty lines. No output (an empty result, or
|
|
* a pure DDL batch) means no rows.
|
|
*/
|
|
function parseJsonRows(stdout: string): Record<string, unknown>[] {
|
|
const joined = stdout
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trimEnd())
|
|
.filter((l) => l.length > 0)
|
|
.join("");
|
|
if (joined.length === 0) return [];
|
|
const parsed = JSON.parse(joined);
|
|
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
|
|
}
|