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.
262 lines
11 KiB
TypeScript
262 lines
11 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"): 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);
|
|
return parseJsonRows(stdout);
|
|
}
|
|
|
|
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
|
|
private async sqlcmd(sql: string, database: 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,
|
|
],
|
|
{ env: { ...process.env, 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)}`);
|
|
}
|
|
|
|
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);
|
|
}
|
|
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> {
|
|
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<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>];
|
|
}
|