The mesh-mssql container goes with its Dockerfile (and the sqlcmd it fetched), build bases and bus credential. The client speaks TDS through the mssql driver its package.json names, inlined into the bundle by the builder (ADR 0198 §4): one session per call as one sqlcmd invocation was, FOR JSON rendering rows exactly as before, the consumer's password checked as a bound parameter. A caller's statement still runs only as the reader login (issue 193); the one-line rule and -x guarded against sqlcmd's own commands and variable substitution, which no longer stand between the caller and the server. The server is reached on loopback at the port the machine published (${port:1433}). The reader test drives a fake session in place of a fake sqlcmd.
399 lines
16 KiB
TypeScript
399 lines
16 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.
|
|
//
|
|
// **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<string, string>): Promise<Record<string, unknown>[]>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
/** How a session is opened — the driver, or a test's fake. */
|
|
export type Connect = (to: Target) => Promise<Session>;
|
|
|
|
/**
|
|
* 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<string, unknown>[][];
|
|
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<string, unknown>[];
|
|
}
|
|
|
|
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<void>;
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<string, string> = {},
|
|
): Promise<Record<string, unknown>[]> {
|
|
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<string, string> = {},
|
|
as: Invocation = { user: this.conn.user, password: this.conn.password },
|
|
): Promise<Record<string, unknown>[]> {
|
|
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<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 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<boolean> {
|
|
// 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<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 ?? ""),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<QueryResult> {
|
|
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<string, unknown>[]): Record<string, unknown>[] {
|
|
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<string, unknown>[]) : [parsed as Record<string, unknown>];
|
|
}
|