diff --git a/modules/mssql/Dockerfile b/modules/mssql/Dockerfile deleted file mode 100644 index 07ac078..0000000 --- a/modules/mssql/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# mssql's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own -# node_modules — the module is compiled against exactly the sdk it will run against. The compiler -# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image -# resolved away. -WORKDIR /app/modules/mssql -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -# **sqlcmd, which this module's client drives, has to be here** — it never was, so every tool failed -# with `spawn sqlcmd ENOENT`. go-sqlcmd is one static binary; fetched at a pinned release and checked -# against its digest, so a build that receives anything else stops here. -FROM ${BUILD_BASE} AS sqlcmd -ARG SQLCMD_VERSION=v1.10.0 -ARG SQLCMD_SHA256=92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987 -RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates bzip2 \ - && curl -fsSL -o /tmp/sqlcmd.tar.bz2 \ - "https://github.com/microsoft/go-sqlcmd/releases/download/${SQLCMD_VERSION}/sqlcmd-linux-amd64.tar.bz2" \ - && echo "${SQLCMD_SHA256} /tmp/sqlcmd.tar.bz2" | sha256sum -c - \ - && tar -xjf /tmp/sqlcmd.tar.bz2 -C /usr/local/bin sqlcmd - -FROM ${RUNTIME_BASE} -COPY --from=sqlcmd /usr/local/bin/sqlcmd /usr/local/bin/sqlcmd -COPY --from=build /app/modules/mssql/dist /app/modules/mssql/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. A container that instead ran only its -# provisioner (`run`) served no tools and emitted no events; a container that named no command -# ran no provisioner at all. -ENV MESH_TOOL_MODULES=/app/modules/mssql/dist/index.js,/app/modules/mssql/dist/tools/index.js,/app/modules/mssql/dist/provisioner/index.js diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index 5bbb78a..a17998b 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -1,22 +1,70 @@ // 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. +// **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 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. +// 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 { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import sql from "mssql"; -const run = promisify(execFile); +/** 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". */ @@ -45,20 +93,17 @@ export interface MssqlConn { */ export const READER = "mesh_mssql_reader"; -/** Who a sqlcmd invocation logs in as, and whether the text is a caller's rather than the module's. */ +/** Who a session logs in as. */ interface Invocation { readonly user: string; readonly password: string; - /** - * A caller's text: sqlcmd substitutes no `$(NAME)` in it, which would read this process's - * environment — the administrator's password among it. (Its own commands are kept out by the - * caller's text never beginning a line; see readOnlyQuery.) - */ - readonly caller: boolean; } export class MssqlClient { - constructor(private readonly conn: MssqlConn) {} + 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; @@ -90,63 +135,41 @@ export class MssqlClient { 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 { - await this.sqlcmd(sql, database); + /** 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 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. + * 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", - variables: Record = {}, + params: Record = {}, ): Promise[]> { 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); + return parseJsonRows(await this.session(wrapped, database, params)); } - /** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */ - private async sqlcmd( - sql: string, + /** The one execution boundary: open a session as `as`, run `text`, close it. */ + private async session( + text: string, database: string, - variables: Record = {}, - as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false }, - ): Promise { - // `-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", as.user, - "-d", database, - ...(as.caller ? ["-x"] : []), - "-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: as.password }, maxBuffer: 16 << 20 }, - ); - return stdout; + 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(); + } } /** @@ -173,7 +196,7 @@ export class MssqlClient { `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. + // CREATE DATABASE must stand alone in its batch; it runs as its own session. await this.exec(`CREATE DATABASE ${ident(database)}`); } @@ -206,15 +229,14 @@ 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 { - // 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. + // 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(N'$(MESHHOLDSPW)', password_hash) = 1) ` + + `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 }, + { 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 @@ -294,35 +316,27 @@ export class MssqlClient { * (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, sql: string): Promise { + async readOnlyQuery(database: string, text: string): Promise { const password = this.conn.readerPassword; if (!password) throw readerMissing(); - // **One line, refused otherwise.** sqlcmd reads a line that BEGINS with `:` or `!!` as its own - // command rather than SQL, and `:!!` starts a program in this container, which holds the - // administrator's password. Its switch for refusing those (-X) makes it ignore -Q in the - // version shipped here, so instead no line of a caller's text can begin one: the text follows - // this module's own on the first line, and a line break in it is refused. Proven on a throwaway - // server: the same text at the start of a line ran a program; mid-line it is a syntax error. - if (/[\r\n]/.test(sql)) { - throw new Error( - "mssql_query: the statement must be one line — sqlcmd takes a line beginning with ':' or " + - "'!!' as a command of its own, which can start a program (novox/hq issue 193)", - ); - } 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 stdout = await this.sqlcmd( - `SET NOCOUNT ON; ${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`, + const rows = await this.session( + `SET NOCOUNT ON; ${stripTrailingSemis(text)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`, database, {}, - { user: READER, password, caller: true }, + { user: READER, password }, ); - const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? ""; - return { command, rows: parseJsonRows(stdout) }; + const command = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? ""; + return { command, rows: parseJsonRows(rows) }; } } @@ -372,18 +386,13 @@ function safeUrl(raw: string): URL | 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. + * 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(stdout: string): Record[] { - const joined = stdout - .split(/\r?\n/) - .map((l) => l.trimEnd()) - .filter((l) => l.length > 0) - .join(""); - if (joined.length === 0) return []; +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]; } diff --git a/modules/mssql/index.ts b/modules/mssql/index.ts index 0232587..faef29f 100644 --- a/modules/mssql/index.ts +++ b/modules/mssql/index.ts @@ -1,9 +1,9 @@ -// mssql's events entrypoint, loaded by the per-node tool host (the provisioner container runs -// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where +// mssql's events entrypoint, launched by the node's runtime beside its tools and provisioner +// (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where // the lifecycle actually happens (novox/hq ADR 0041/0042): // module.mssql.database.provisioned — a consumer's database + login/user was created // module.mssql.database.deprovisioned — that database was removed -// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a +// Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a // database and who lost one — observability the provider itself is best placed to log. import { on } from "@novox/mesh-sdk/events"; diff --git a/modules/mssql/module.json b/modules/mssql/module.json index 0b86a84..28802a7 100644 --- a/modules/mssql/module.json +++ b/modules/mssql/module.json @@ -38,16 +38,9 @@ }, "own-secrets": { "sa": "${dir:state}/sa.secret", - "broker": "${dir:mesh-state}/broker", "reader": "${dir:state}/reader.secret" }, "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, { "id": "state", "type": "directory", @@ -93,46 +86,30 @@ "${dir:data}:/var/opt/mssql" ], "secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint" - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-mssql", - "network": "mssql", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:grants}:/var/lib/mssql/grants:ro", - "${dir:state}/sa.secret:/run/secrets/sa:ro", - "${dir:state}/reader.secret:/run/secrets/reader:ro" - ], - "env": { - "MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/sa", - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_RECEIVES": "/var/lib/mssql/grants/mesh.json", - "MESH_MSSQL_READER_PASSWORD_FILE": "/run/secrets/reader" - }, - "artifact": "runtime" } ], "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], "artifacts": [ { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_PROVISION_MSSQL": "mssql://sa@127.0.0.1:${port:1433}/master", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/sa.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret" + } } ] } diff --git a/modules/mssql/mssql.d.ts b/modules/mssql/mssql.d.ts new file mode 100644 index 0000000..d58a508 --- /dev/null +++ b/modules/mssql/mssql.d.ts @@ -0,0 +1,32 @@ +// Ambient types for `mssql`, which ships its types only in the separate `@types/mssql` package. This +// declares the slice client.ts uses — the precedent mesh-catalog's pg.d.ts sets — so the module +// type-checks without deciding what runs: the real `mssql` is the package.json dependency the +// builder installs and inlines into the bundle (novox/hq ADR 0198 §4). +declare module "mssql" { + interface Result { + recordsets: unknown; + } + interface Request { + input(name: string, type: unknown, value: unknown): Request; + query(text: string): Promise; + batch(text: string): Promise; + } + class ConnectionPool { + constructor(config: { + server: string; + port?: number; + user?: string; + password?: string; + database?: string; + options?: { encrypt?: boolean; trustServerCertificate?: boolean }; + pool?: { min?: number; max?: number }; + connectionTimeout?: number; + requestTimeout?: number; + }); + connect(): Promise; + request(): Request; + close(): Promise; + } + const sql: { ConnectionPool: typeof ConnectionPool; NVarChar: unknown }; + export default sql; +} diff --git a/modules/mssql/package.json b/modules/mssql/package.json index 3a9be3b..f98fb4a 100644 --- a/modules/mssql/package.json +++ b/modules/mssql/package.json @@ -5,11 +5,12 @@ "type": "module", "private": true, "scripts": { - "build": "tsc client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "build": "tsc mssql.d.ts client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { - "@novox/mesh-sdk": "^0.1.1" + "@novox/mesh-sdk": "^0.1.1", + "mssql": "^11.0.2" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mssql/test/reader.test.ts b/modules/mssql/test/reader.test.ts index 2d7c7e7..c33fc1b 100644 --- a/modules/mssql/test/reader.test.ts +++ b/modules/mssql/test/reader.test.ts @@ -1,96 +1,83 @@ // What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the -// reader login and never as the administrator, with sqlcmd's variable substitution off, on one line -// that follows the module's own — a line break is refused before sqlcmd starts — and with no -// transaction wrapped around it as text. Without the reader's password the statement is refused. +// reader login and never as the administrator, with no transaction wrapped around it as text, and +// without the reader's password the statement is refused. // -// sqlcmd is a fake on PATH that records each call's login, flags and text. That the reader cannot -// write is the server's to enforce and was proven against a real server; this holds the module to -// asking for it. Run against the compiled module (npm test builds first), the way the runtime loads it. +// The driver is a fake session that records each call's login, database, text and bound +// parameters. That the reader cannot write is the server's to enforce and was proven against a real +// server; this holds the module to asking for it. Run against the compiled module (npm test builds +// first), the way the runtime loads it. -import { test, before, after } from "node:test"; +import { test } from "node:test"; import assert from "node:assert/strict"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { MssqlClient, READER } from "../dist/client.js"; +import { MssqlClient, READER, type Connect, type Target } from "../dist/client.js"; -let dir: string; -let log: string; -const originalPath = process.env.PATH; +interface Call extends Target { + text: string; + params: Record; +} -before(async () => { - dir = await mkdtemp(join(tmpdir(), "mssql-reader-")); - log = join(dir, "calls.jsonl"); - await writeFile(join(dir, "sqlcmd"), `#!/usr/bin/env node -const fs = require("node:fs"); -const args = process.argv.slice(2); -const at = (flag) => args[args.indexOf(flag) + 1]; -fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ - user: at("-U"), database: at("-d"), sql: at("-Q"), noVariables: args.includes("-x"), - password: process.env.SQLCMDPASSWORD, -}) + "\\n"); -const sql = at("-Q"); -if (/FROM sys.server_principals/.test(sql)) process.stdout.write(""); -else if (/FOR JSON/.test(sql)) process.stdout.write('[{"name":"alpha","n":1}]\\n'); -`); - await chmod(join(dir, "sqlcmd"), 0o755); - process.env.PATH = `${dir}:${originalPath}`; -}); - -after(async () => { - process.env.PATH = originalPath; - await rm(dir, { recursive: true, force: true }); -}); - -async function calls(): Promise[]> { - const text = await readFile(log, "utf8").catch(() => ""); - await writeFile(log, ""); - return text.split("\n").filter(Boolean).map((line) => JSON.parse(line)); +function recording(): { connect: Connect; calls: Call[] } { + const calls: Call[] = []; + const connect: Connect = async (to) => ({ + async run(text, params = {}) { + calls.push({ ...to, text, params }); + if (/FROM sys.server_principals/.test(text)) return []; + // FOR JSON answers its document split across rows of one column. + if (/FOR JSON/.test(text)) return [{ JSON_F52E: '[{"name":"al' }, { JSON_F52E: 'pha","n":1}]' }]; + return []; + }, + async close() {}, + }); + return { connect, calls }; } const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" }; -test("a caller's statement runs as the reader, without variables, on the module's first line", async () => { - const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); +test("a caller's statement runs as the reader, as it was written, on the database it names", async () => { + const { connect, calls } = recording(); + const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect); const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p"); - const asked = (await calls()).at(-1)!; + const asked = calls.at(-1)!; assert.equal(asked.user, READER, "the statement never runs as the administrator"); assert.equal(asked.password, "reader-secret"); - assert.equal(asked.noVariables, true, "no $(NAME) is substituted in a caller's text"); - const [first] = String(asked.sql).split("\n"); - assert.ok(first.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)'"), "the caller's text never begins a line"); - assert.doesNotMatch(String(asked.sql), /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text"); - assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }]); + assert.equal(asked.database, "inventory"); + assert.ok(asked.text.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)' AS p\nFOR JSON PATH"), + "the caller's text reaches the server unaltered"); + assert.doesNotMatch(asked.text, /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text"); + assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }], "a FOR JSON document split across rows is reassembled"); assert.equal(result.command, "SELECT"); }); -test("a line break in a caller's statement is refused before sqlcmd starts", async () => { - const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); - for (const sql of ["SELECT 1\n:!! id", "SELECT 1\r\n:!! id", "SELECT 1\r:!! id"]) { - await assert.rejects(client.readOnlyQuery("inventory", sql), /must be one line/); - } - assert.deepEqual(await calls(), []); -}); - test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => { - const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); + const { connect, calls } = recording(); + const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect); await client.readOnlyQuery("inventory", "SELECT 1 AS x"); await client.readOnlyQuery("inventory", "SELECT 2 AS x"); - const made = await calls(); - const asAdmin = made.filter((c) => c.user === "sa").map((c) => String(c.sql)); + const asAdmin = calls.filter((c) => c.user === "sa").map((c) => c.text); assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`))); assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s))); assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`)); assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`)); assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call"); - assert.equal(made.filter((c) => c.user === READER).length, 2); + assert.equal(calls.filter((c) => c.user === READER).length, 2); }); test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => { - const client = new MssqlClient(conn); + const { connect, calls } = recording(); + const client = new MssqlClient(conn, connect); await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/); - assert.deepEqual(await calls(), []); + assert.deepEqual(calls, []); +}); + +test("a consumer's password is checked as a bound parameter, never in the text", async () => { + const { connect, calls } = recording(); + const client = new MssqlClient(conn, connect); + await client.holdsLogin("shop", "shop_login", "minted-secret"); + const asked = calls[0]; + assert.equal(asked.params.meshholdspw, "minted-secret"); + assert.doesNotMatch(asked.text, /minted-secret/); + assert.match(asked.text, /PWDCOMPARE\(@meshholdspw, password_hash\)/); }); diff --git a/modules/mssql/tsconfig.json b/modules/mssql/tsconfig.json index 51f4046..20a5be3 100644 --- a/modules/mssql/tsconfig.json +++ b/modules/mssql/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] + "include": ["mssql.d.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] }