diff --git a/modules/mesh-catalog/Dockerfile b/modules/mesh-catalog/Dockerfile deleted file mode 100644 index ed67a61..0000000 --- a/modules/mesh-catalog/Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -# mesh-catalog's runtime: the tool runtime, carrying the catalogue's compiled graph, its consumer -# of what the builder announces, and its tools. -# -# **Built from this module's own directory and nothing else.** The sdk is in the base image, so -# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a -# repository and a path (novox/hq ADR 0069) rather than only on a workstation with the siblings. -# -# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in. -# They are different images on purpose — the first carries a compiler and the second must not, or -# every running container would carry one it never invokes. The mesh answers both with the copies it -# holds, because a fingerprint written here would name one particular copy and no other mesh has it -# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build -# nobody told stops here and says which module to build first. -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. -WORKDIR /app/modules/mesh-catalog -COPY . . -# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are -# symlinks to a launcher that requires its library relatively — resolved away when the base image -# was assembled. -RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -# **A module may need something the base image does not carry.** The base holds what every module -# needs — the sdk, the broker client — and a postgres driver is not that: the one other module that -# reaches a database shells out to psql instead. So the catalogue brings its own. -# -# Installed into an empty directory rather than into the module's, because the module's package.json -# also names `@novox/mesh-sdk`, which is not on any registry — it is in the base image. Asking npm to -# resolve this module's dependencies would therefore fail on the one it already has. -RUN mkdir -p /deps && cd /deps && \ - npm install --omit=dev --no-audit --no-fund --no-package-lock pg@8 - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/mesh-catalog/dist /app/modules/mesh-catalog/dist -# Beside the compiled code, so `pg` resolves from it while `@novox/mesh-sdk` keeps walking up to the -# base image's own node_modules — the module gets its extra dependency without shadowing the sdk it -# was compiled against. -COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules -# Both entrypoints, loaded in serve mode. -# -# **A consumer cannot be started with `run`.** That mode imports an entrypoint without binding a -# broker — it is for a step that does its work offline and exits — and the catalogue's whole job is -# to listen for what the builder announces. Serve binds the broker first, then imports these, so -# `on()` has something to subscribe to. -ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js - -# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named -# here, beside the entrypoints above, because the module knows which of its files prepares its state -# and nothing else could: the mesh asks one word and this says what answers it. -ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index 371f10b..b5f6995 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -25,9 +25,6 @@ "secrets": { "postgres-database": "${dir:state}/database.secret" }, - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, "consumes": [ "mesh-build-machine.built", "mesh-controller.built-before" @@ -38,14 +35,7 @@ "rebuild-needed", "catching-up" ], - "prepares": true, "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, { "id": "state", "type": "directory", @@ -60,43 +50,41 @@ "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" }, { - "id": "runtime", - "type": "container", - "name": "mesh-catalog", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:state}:/run/state", - "${dir:state}/database.url:/run/secrets/database-url:ro" + "id": "prepare", + "type": "process", + "name": "mesh-catalog-prepare", + "artifact": "code", + "run": [ + "node", + "prepare/index.js" ], + "run-once": true, "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "DATABASE_URL_FILE": "/run/secrets/database-url" + "DATABASE_URL_FILE": "${dir:state}/database.url" }, - "artifact": "runtime", "restart-on": [ "database-url" ] } ], "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", + "prepare/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + } } ] } diff --git a/modules/mesh-catalog/pg.d.ts b/modules/mesh-catalog/pg.d.ts index 023b330..4e50786 100644 --- a/modules/mesh-catalog/pg.d.ts +++ b/modules/mesh-catalog/pg.d.ts @@ -1,9 +1,9 @@ // Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg` // package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses — // the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts, -// listed in tsconfig `include`, default-imported). The real `pg` is installed into the module's -// runtime image (package.json `dependencies`; novox/hq ADR 0052), so this types the code without -// deciding what runs. +// listed in tsconfig `include`, default-imported). The real `pg` is the package.json dependency the +// builder installs and inlines into the module's bundle (novox/hq ADR 0198 §4), so this types the +// code without deciding what runs. declare module "pg" { /** One checked-out connection. Needed because registering a module-version and its edges is one * act: a half-written registration is a graph that lies about what something was built against. */ diff --git a/modules/mesh-catalog/prepare/index.ts b/modules/mesh-catalog/prepare/index.ts index ed05f93..4cd3739 100644 --- a/modules/mesh-catalog/prepare/index.ts +++ b/modules/mesh-catalog/prepare/index.ts @@ -7,8 +7,9 @@ // nothing anywhere said so. // // Nothing here connects to the broker. Preparation runs before the version that would use it, so -// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this -// process exiting non-zero is how the host knows not to start the runtime. +// there is nothing yet to talk to: the host runs this file as a run-once process, with the module's +// words and no bus (novox/hq ADR 0198 §3), before the node's runtime is started with the version +// that needs it, and this process exiting non-zero is how the host knows the step did not complete. import { Graph } from "../store.js"; const graph = Graph.fromEnv(); diff --git a/modules/mongodb/Dockerfile b/modules/mongodb/Dockerfile deleted file mode 100644 index 05f3aef..0000000 --- a/modules/mongodb/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# mongodb'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/mongodb -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 - -FROM ${RUNTIME_BASE} -# mongodb's client shells out to `mongosh`, installed from MongoDB's own apt repo so its shared -# libraries come with it — copying the bare binary out of the mongo image leaves it unable to load. -RUN apt-get update && apt-get install -y --no-install-recommends gnupg curl ca-certificates \ - && curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/mongodb.gpg] https://repo.mongodb.org/apt/debian bookworm/mongodb-org/7.0 main" > /etc/apt/sources.list.d/mongodb.list \ - && apt-get update && apt-get install -y --no-install-recommends mongodb-mongosh \ - && rm -rf /var/lib/apt/lists/* -COPY --from=build /app/modules/mongodb/dist /app/modules/mongodb/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/mongodb/dist/index.js,/app/modules/mongodb/dist/tools/index.js,/app/modules/mongodb/dist/provisioner/index.js diff --git a/modules/mongodb/client.ts b/modules/mongodb/client.ts index ae8c666..946de94 100644 --- a/modules/mongodb/client.ts +++ b/modules/mongodb/client.ts @@ -1,19 +1,16 @@ // mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this // module's tools and its provisioner import it, and nothing outside mongodb does. // -// Commands run through `mongosh`, not a wire-protocol driver: the module may take NO npm dependency -// beyond @novox/mesh-sdk, and hand-rolling the MongoDB wire protocol + SCRAM auth is more surface -// than this should carry — so it shells out to the shell the mongodb image ships, the same way -// postgres drives itself through `psql`, minio through `mc` and mailu through doveadm. One boundary, -// `evalJs()`, and every method is built on it: a snippet of JavaScript is evaluated server-side and -// its result comes back as EJSON on stdout. +// **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to +// `mongosh`, which the module's container installed from MongoDB's apt repository; the module's code +// now runs in the node's runtime, on machines whose system carries no mongosh, so it speaks to the +// server through the official `mongodb` driver its package.json names — installed and inlined into +// the bundle by the builder. One connection per call, as one mongosh invocation was: the module is +// called rarely, and a pool held open across calls would hold a credential the mesh may rotate. 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); +import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb"; export interface DatabaseInfo { readonly name: string; @@ -59,32 +56,26 @@ export class MongoClient { return this.conn.port; } - /** The admin connection URI mongosh authenticates with, credentials percent-encoded. */ + /** The admin connection URI, credentials percent-encoded. */ private uri(): string { const u = encodeURIComponent(this.conn.user); const p = encodeURIComponent(this.conn.password); const a = encodeURIComponent(this.conn.authSource); - return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}`; + return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}&directConnection=true`; } /** - * Evaluate a JavaScript snippet server-side through `mongosh` and parse the JSON it prints (see - * header). The snippet MUST `print()` exactly one JSON document as its only stdout — every method - * below ends in `print(EJSON.stringify(...))`. `--quiet` suppresses the shell banner so stdout is - * the JSON alone; a non-zero exit (auth failure, bad command) rejects here rather than returning - * a partial success. + * The one execution boundary: connect as the administrator, do `work`, and close — a failure to + * connect or to authenticate rejects here rather than returning a partial success. */ - async evalJs(js: string): Promise { - const { stdout } = await run( - "mongosh", - [this.uri(), "--quiet", "--eval", js], - { maxBuffer: 16 << 20 }, - ); - const text = stdout.trim(); - if (text.length === 0) { - throw new Error("mongosh returned no output — the eval printed nothing"); + private async admin(work: (client: Driver) => Promise): Promise { + const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 }); + try { + await client.connect(); + return await work(client); + } finally { + await client.close(); } - return JSON.parse(text) as T; } /** @@ -94,19 +85,16 @@ export class MongoClient { * password and roles, so a rotated credential converges. */ async createDatabaseAndUser(database: string, user: string, password: string): Promise { - const js = ` -const target = db.getSiblingDB(${lit(database)}); -let existing = null; -try { existing = target.getUser(${lit(user)}); } catch (e) { existing = null; } -const roles = [{ role: "dbOwner", db: ${lit(database)} }]; -if (existing) { - target.updateUser(${lit(user)}, { pwd: ${lit(password)}, roles: roles }); -} else { - target.createUser({ user: ${lit(user)}, pwd: ${lit(password)}, roles: roles }); -} -print(EJSON.stringify({ ok: 1 })); -`; - await this.evalJs<{ ok: number }>(js); + await this.admin(async (client) => { + const target = client.db(database); + const roles = [{ role: "dbOwner", db: database }]; + const found = await target.command({ usersInfo: user }); + if (Array.isArray(found.users) && found.users.length > 0) { + await target.command({ updateUser: user, pwd: password, roles }); + } else { + await target.command({ createUser: user, pwd: password, roles }); + } + }); } /** @@ -115,45 +103,43 @@ print(EJSON.stringify({ ok: 1 })); * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). */ async canAuthenticateAs(database: string, user: string, password: string): Promise { - // Connected without credentials, then authenticated inside the eval from the environment, so - // the consumer's password is neither on argv nor in the message of a failed command. - const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`; - const js = - "const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" + - "t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" + - "print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"; - let stdout: string; + // Credentials as options, never in a URI, so the consumer's password is in no message a failed + // connection prints. + const client = new Driver(`mongodb://${this.conn.host}:${this.conn.port}/?directConnection=true`, { + auth: { username: user, password }, + authSource: database, + serverSelectionTimeoutMS: 10_000, + }); try { - ({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], { - env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password }, - timeout: 30_000, - })); + await client.connect(); + const status = await client.db(database).command({ connectionStatus: 1 }); + const roles = (status.authInfo?.authenticatedUserRoles ?? []) as { role: string; db: string }[]; + return roles.some((r) => r.role === "dbOwner" && r.db === database); } catch (err) { - const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`; - if (/Authentication failed|AuthenticationFailed/i.test(text)) return false; - throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`); + if (isAuthFailure(err)) return false; + throw new Error(`mongodb could not check ${user}: ${String((err as Error).message).split("\n")[0]}`); + } finally { + await client.close(); } - const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[]; - return roles.some((r) => r.role === "dbOwner" && r.db === database); } /** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the * user is removed first so a re-grant of the same login starts clean. */ async dropDatabaseAndUser(database: string, user: string): Promise { - const js = ` -const target = db.getSiblingDB(${lit(database)}); -try { target.dropUser(${lit(user)}); } catch (e) {} -target.dropDatabase(); -print(EJSON.stringify({ ok: 1 })); -`; - await this.evalJs<{ ok: number }>(js); + await this.admin(async (client) => { + const target = client.db(database); + try { + await target.command({ dropUser: user }); + } catch (err) { + if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound + } + await target.dropDatabase(); + }); } /** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */ async listDatabases(): Promise { - const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>( - `print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`, - ); + const res = await this.admin((client) => client.db("admin").admin().listDatabases()); return (res.databases ?? []) .map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) })) .sort((a, b) => a.name.localeCompare(b.name)); @@ -162,6 +148,8 @@ print(EJSON.stringify({ ok: 1 })); /** * Run a read-only `find` against a collection in a named database, for the mongodb_query tool. * `find` mutates nothing; the limit is capped so a tool call cannot stream an unbounded result. + * Documents come back as relaxed Extended JSON — an ObjectId as `{"$oid": …}` — exactly as the + * shell's `EJSON.stringify` rendered them before. */ async find( database: string, @@ -170,26 +158,29 @@ print(EJSON.stringify({ ok: 1 })); limit: number, ): Promise[]> { const capped = Math.max(1, Math.min(limit, 1000)); - const js = - `print(EJSON.stringify(` + - `db.getSiblingDB(${lit(database)}).getCollection(${lit(collection)})` + - `.find(${JSON.stringify(filter)}).limit(${capped}).toArray()` + - `));`; - return this.evalJs[]>(js); + const docs = await this.admin((client) => + client + .db(database) + .collection(collection) + .find(BSON.EJSON.deserialize(filter as Document, { relaxed: true }) as Document) + .limit(capped) + .toArray(), + ); + return BSON.EJSON.serialize(docs, { relaxed: true }) as Record[]; } } +/** An authentication failure, as the server or the driver reports it. */ +function isAuthFailure(err: unknown): boolean { + if (err instanceof MongoServerError && err.code === 18) return true; // 18: AuthenticationFailed + return /Authentication failed|AuthenticationFailed/i.test(String((err as Error)?.message ?? "")); +} + /** Generate a URL-safe password. */ export function generatePassword(): string { return randomBytes(24).toString("base64url"); } -/** Embed a value as a JavaScript literal inside a mongosh snippet — JSON.stringify escapes quotes, - * backslashes and control characters, so a string cannot break out of the snippet. */ -function lit(val: unknown): string { - return JSON.stringify(val); -} - function readSecretFile(path: string | undefined): string | undefined { if (!path) return undefined; try { diff --git a/modules/mongodb/index.ts b/modules/mongodb/index.ts index ebca8d5..24dd511 100644 --- a/modules/mongodb/index.ts +++ b/modules/mongodb/index.ts @@ -1,9 +1,9 @@ -// mongodb'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 +// mongodb'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.mongodb.database.provisioned — a consumer's database + owning user was created // module.mongodb.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/mongodb/module.json b/modules/mongodb/module.json index 8aa8a40..1f70756 100644 --- a/modules/mongodb/module.json +++ b/modules/mongodb/module.json @@ -39,17 +39,9 @@ "mongodb-database": "${dir:grants}" }, "own-secrets": { - "root": "${dir:state}/root.secret", - "broker": "${dir:mesh-state}/broker" + "root": "${dir:state}/root.secret" }, - "secrets-owner": "999:999", "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, { "id": "state", "type": "directory", @@ -71,6 +63,14 @@ "type": "network", "name": "mongodb" }, + { + "id": "server-root", + "type": "file", + "path": "${dir:state}/server-root.secret", + "mode": "0400", + "owner": "999:999", + "content": "${secret:root}" + }, { "id": "server", "type": "container", @@ -86,46 +86,31 @@ ], "volumes": [ "${dir:data}:/data/db", - "${dir:state}/root.secret:/run/secrets/root:ro" + "${dir:state}/server-root.secret:/run/secrets/root:ro" ] - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-mongodb", - "network": "mongodb", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:grants}:${dir:grants}:ro", - "${dir:state}/root.secret:/run/secrets/root:ro" - ], - "env": { - "MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root", - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_RECEIVES": "${dir:grants}/mesh.json" - }, - "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_MONGODB": "mongodb://root@127.0.0.1:${port:27017}/admin?authSource=admin", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } } ] } diff --git a/modules/mongodb/package.json b/modules/mongodb/package.json index 479e7ea..be35a99 100644 --- a/modules/mongodb/package.json +++ b/modules/mongodb/package.json @@ -5,7 +5,8 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.1" + "@novox/mesh-sdk": "^0.1.1", + "mongodb": "^6.21.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mongodb/provisioner/index.ts b/modules/mongodb/provisioner/index.ts index aaa279d..3b905a1 100644 --- a/modules/mongodb/provisioner/index.ts +++ b/modules/mongodb/provisioner/index.ts @@ -11,8 +11,7 @@ // same-named database under exactly that login — a name the consumer cannot learn is a database it // cannot reach. // -// The commands run through MongoClient.evalJs(), which is the module's one execution boundary (see -// client.ts). +// The commands run through MongoClient, the official driver inside this bundle (see client.ts). import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; diff --git a/modules/mongodb/tools/index.ts b/modules/mongodb/tools/index.ts index b45b804..e200037 100644 --- a/modules/mongodb/tools/index.ts +++ b/modules/mongodb/tools/index.ts @@ -1,6 +1,6 @@ // mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They -// return structured data; the mesh serves them through the sdk's tool harness. Both call through -// MongoClient.evalJs(), the module's one execution boundary (see client.ts). +// return structured data; the mesh serves them through the sdk's tool harness. Both call the server +// through MongoClient, the driver inside this bundle (see client.ts). import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { MongoClient } from "../client.js"; 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"] }