mongodb: its handlers, tools and provisioner run in the node's runtime, through the driver in its bundle (hq ADR 0198)
The mesh-mongodb container goes with its Dockerfile, build bases and bus credential. Its client shelled out to mongosh, which no machine's system carries, so it now speaks to the server through the official mongodb driver its package.json names, inlined into the bundle by the builder (ADR 0198 §4); the tools answer exactly as before (relaxed Extended JSON). The server is reached on loopback at the port the machine published (${port:27017}). The root secret was owned by the mongo image's user (secrets-owner 999:999), which the runtime's account cannot read; the module's own copy is now the runtime's, and the server is given its own 999-owned copy rendered from the same secret.
This commit is contained in:
+70
-79
@@ -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<T>(js: string): Promise<T> {
|
||||
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<T>(work: (client: Driver) => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
// 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<void> {
|
||||
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<DatabaseInfo[]> {
|
||||
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<Record<string, unknown>[]> {
|
||||
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<Record<string, unknown>[]>(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<string, unknown>[];
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
|
||||
Reference in New Issue
Block a user