// 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. // // **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to // `sqlcmd`, a binary the module's container fetched; the module's code now runs in the node's // runtime, on machines whose system carries no SQL Server client, so it speaks TDS through the // `mssql` driver its package.json names — installed and inlined into the bundle by the builder. One // boundary, `session()`, and every method is built on it: a connection as one login to one database, // opened for one call and closed after, as one sqlcmd invocation was. // // Structured rows still come back as JSON rendered by SQL Server itself (`FOR JSON`), so a tool's // answer is shaped exactly as it was: SQL Server owns the quoting and typing. import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import sql from "mssql"; /** Where a session connects, and as whom. */ export interface Target { readonly host: string; readonly port: number; readonly user: string; readonly password: string; readonly database: string; } /** One login's connection to one database: run a batch, answer the rows of its last result set. */ export interface Session { /** `params` are bound as NVARCHAR parameters (`@name`), never written into the text. */ run(text: string, params?: Record): Promise[]>; close(): Promise; } /** How a session is opened — the driver, or a test's fake. */ export type Connect = (to: Target) => Promise; /** * The driver's session: TLS, trusting the self-signed certificate the mssql image ships with (what * sqlcmd's `-C` did), one connection, closed with the session. */ export const connectWithDriver: Connect = async (to) => { const pool = new sql.ConnectionPool({ server: to.host, port: to.port, user: to.user, password: to.password, database: to.database, options: { encrypt: true, trustServerCertificate: true }, pool: { min: 0, max: 1 }, connectionTimeout: 15_000, requestTimeout: 60_000, }); await pool.connect(); return { async run(text, params = {}) { const request = pool.request(); const names = Object.keys(params); for (const name of names) request.input(name, sql.NVarChar, params[name]); // A batch when nothing is bound — CREATE DATABASE must stand alone in its batch, which a // parameterised query (sp_executesql) is not. const result = names.length > 0 ? await request.query(text) : await request.batch(text); const sets = (result.recordsets ?? []) as Record[][]; return sets.length > 0 ? sets[sets.length - 1] : []; }, close: () => pool.close(), }; }; export interface QueryResult { /** The leading keyword of the statement, e.g. "SELECT", "CREATE". */ readonly command: string; readonly rows: Record[]; } export interface MssqlConn { readonly host: string; readonly port: number; readonly user: string; readonly password: string; /** * The read-only login's password, which the mesh mints for this module (`own-secrets.reader`). * Absent when the mesh has not delivered it: then a caller's statement is refused, never run as * the administrator (novox/hq issue 193). */ readonly readerPassword?: string; } /** * The login a caller's statement runs as (novox/hq issue 193). It may connect to every database and * read every table, and holds no other permission. A statement cannot climb out of a login the way it * could out of a transaction wrapped around it as text, and the administrator — who can run programs * on the server — never runs a caller's text. */ export const READER = "mesh_mssql_reader"; /** Who a session logs in as. */ interface Invocation { readonly user: string; readonly password: string; } export class MssqlClient { constructor( private readonly conn: MssqlConn, private readonly connect: Connect = connectWithDriver, ) {} /** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */ private readerReady?: Promise; /** * 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"); } const readerPassword = env.MESH_MSSQL_READER_PASSWORD ?? readSecretFile(env.MESH_MSSQL_READER_PASSWORD_FILE); return new MssqlClient({ host, port, user, password, readerPassword }); } 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). A failed statement rejects. */ async exec(text: string, database = "master"): Promise { await this.session(text, 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 it answers * (split across rows for a large result, and reassembled here) is parsed. An empty result yields * no rows — an empty array. `params` are bound as `@name`, never written into the text. */ async query( select: string, database = "master", params: Record = {}, ): Promise[]> { const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`; return parseJsonRows(await this.session(wrapped, database, params)); } /** The one execution boundary: open a session as `as`, run `text`, close it. */ private async session( text: string, database: string, params: Record = {}, as: Invocation = { user: this.conn.user, password: this.conn.password }, ): Promise[]> { const session = await this.connect({ host: this.conn.host, port: this.conn.port, user: as.user, password: as.password, database, }); try { return await session.run(text, params); } finally { await session.close(); } } /** * 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 { 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 session. 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 { // The password is a bound parameter, never inside the query text, so it is in no message of a // failed statement. 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(@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 { 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 ?? ""), })); } /** * Make the read-only login, idempotently, with the password the mesh minted for it: it may connect * to every database and read every table, and is taken out of the administrators' role should * anyone have put it there. Run as the administrator, because only it can make a login. */ async ensureReader(): Promise { const password = this.conn.readerPassword; if (!password) throw readerMissing(); const logins = await this.query( `SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(READER)}`, ); if (logins.length === 0) { await this.exec( `CREATE LOGIN ${ident(READER)} WITH PASSWORD = ${literal(password)}, CHECK_POLICY = OFF`, ); } else { await this.exec(`ALTER LOGIN ${ident(READER)} WITH PASSWORD = ${literal(password)}`); await this.exec(`ALTER LOGIN ${ident(READER)} ENABLE`); } await this.exec( `IF IS_SRVROLEMEMBER('sysadmin', ${literal(READER)}) = 1 ` + `ALTER SERVER ROLE sysadmin DROP MEMBER ${ident(READER)}`, ); await this.exec(`GRANT CONNECT ANY DATABASE TO ${ident(READER)}`); await this.exec(`GRANT SELECT ALL USER SECURABLES TO ${ident(READER)}`); } /** * Run a caller's SELECT against a named database as the read-only login, for the mssql_query tool * (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the * rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call * is refused. * * The text goes to the server as it is, over the driver: there is no client between that reads a * line of its own (sqlcmd's `:!!`, which could start a program) or substitutes `$(NAME)` from this * process's environment, so neither the one-line rule nor `-x` has anything left to guard. */ async readOnlyQuery(database: string, text: string): Promise { const password = this.conn.readerPassword; if (!password) throw readerMissing(); this.readerReady ??= this.ensureReader().catch((err) => { this.readerReady = undefined; // asked again next call, not failed for the process's life throw err; }); await this.readerReady; const rows = await this.session( `SET NOCOUNT ON; ${stripTrailingSemis(text)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`, database, {}, { user: READER, password }, ); const command = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? ""; return { command, rows: parseJsonRows(rows) }; } } function readerMissing(): Error { return new Error( "the read-only login's password was not delivered (own-secrets.reader, " + "MESH_MSSQL_READER_PASSWORD_FILE), so the statement is refused rather than run as the " + "administrator (novox/hq issue 193)", ); } /** 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 answers. SQL Server splits a large FOR JSON result into * ~2033-character chunks, one per row of a single column, so the document is reassembled by * concatenating that column in order. No rows (an empty result, or a pure DDL batch) means none. */ function parseJsonRows(rows: Record[]): Record[] { const joined = rows.map((row) => String(Object.values(row)[0] ?? "")).join(""); if (joined.trim().length === 0) return []; const parsed = JSON.parse(joined); return Array.isArray(parsed) ? (parsed as Record[]) : [parsed as Record]; }