mssql: its handlers, tools and provisioner run in the node's runtime, through the driver in its bundle (hq ADR 0198)
The mesh-mssql container goes with its Dockerfile (and the sqlcmd it fetched), build bases and bus credential. The client speaks TDS through the mssql driver its package.json names, inlined into the bundle by the builder (ADR 0198 §4): one session per call as one sqlcmd invocation was, FOR JSON rendering rows exactly as before, the consumer's password checked as a bound parameter. A caller's statement still runs only as the reader login (issue 193); the one-line rule and -x guarded against sqlcmd's own commands and variable substitution, which no longer stand between the caller and the server. The server is reached on loopback at the port the machine published (${port:1433}). The reader test drives a fake session in place of a fake sqlcmd.
This commit is contained in:
@@ -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
|
||||
+107
-98
@@ -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<string, string>): Promise<Record<string, unknown>[]>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** How a session is opened — the driver, or a test's fake. */
|
||||
export type Connect = (to: Target) => Promise<Session>;
|
||||
|
||||
/**
|
||||
* The driver's session: TLS, trusting the self-signed certificate the mssql image ships with (what
|
||||
* sqlcmd's `-C` did), one connection, closed with the session.
|
||||
*/
|
||||
export const connectWithDriver: Connect = async (to) => {
|
||||
const pool = new sql.ConnectionPool({
|
||||
server: to.host,
|
||||
port: to.port,
|
||||
user: to.user,
|
||||
password: to.password,
|
||||
database: to.database,
|
||||
options: { encrypt: true, trustServerCertificate: true },
|
||||
pool: { min: 0, max: 1 },
|
||||
connectionTimeout: 15_000,
|
||||
requestTimeout: 60_000,
|
||||
});
|
||||
await pool.connect();
|
||||
return {
|
||||
async run(text, params = {}) {
|
||||
const request = pool.request();
|
||||
const names = Object.keys(params);
|
||||
for (const name of names) request.input(name, sql.NVarChar, params[name]);
|
||||
// A batch when nothing is bound — CREATE DATABASE must stand alone in its batch, which a
|
||||
// parameterised query (sp_executesql) is not.
|
||||
const result = names.length > 0 ? await request.query(text) : await request.batch(text);
|
||||
const sets = (result.recordsets ?? []) as Record<string, unknown>[][];
|
||||
return sets.length > 0 ? sets[sets.length - 1] : [];
|
||||
},
|
||||
close: () => pool.close(),
|
||||
};
|
||||
};
|
||||
|
||||
export interface QueryResult {
|
||||
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
|
||||
@@ -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<void>;
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await this.session(text, database);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
|
||||
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document 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<string, string> = {},
|
||||
params: Record<string, string> = {},
|
||||
): 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, 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<string, string> = {},
|
||||
as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false },
|
||||
): 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", 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<string, string> = {},
|
||||
as: Invocation = { user: this.conn.user, password: this.conn.password },
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const session = await this.connect({
|
||||
host: this.conn.host, port: this.conn.port, user: as.user, password: as.password, database,
|
||||
});
|
||||
try {
|
||||
return await session.run(text, params);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<boolean> {
|
||||
// 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<QueryResult> {
|
||||
async readOnlyQuery(database: string, text: string): Promise<QueryResult> {
|
||||
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<string, unknown>[] {
|
||||
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<string, unknown>[]): Record<string, unknown>[] {
|
||||
const joined = rows.map((row) => String(Object.values(row)[0] ?? "")).join("");
|
||||
if (joined.trim().length === 0) return [];
|
||||
const parsed = JSON.parse(joined);
|
||||
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
+19
-42
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+32
@@ -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<Result>;
|
||||
batch(text: string): Promise<Result>;
|
||||
}
|
||||
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<ConnectionPool>;
|
||||
request(): Request;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
const sql: { ConnectionPool: typeof ConnectionPool; NVarChar: unknown };
|
||||
export default sql;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<Record<string, unknown>[]> {
|
||||
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\)/);
|
||||
});
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user