Files
mesh-catalog/modules/mongodb/client.ts
T
jschoubben 1fb7ca3d72 A withdrawn consumer keeps its data, in every provider that holds some (hq issue 241)
mssql disables the login, mongodb takes the user's roles, minio revokes the key and keeps the bucket,
mailu disables the mailbox, gitea prohibits the login instead of purging the user and their
repositories, umami keeps the website. Each provider's create already enables what this locks.
2026-10-05 00:34:40 +02:00

212 lines
8.6 KiB
TypeScript

// 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.
//
// **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 { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb";
export interface DatabaseInfo {
readonly name: string;
readonly sizeBytes: number;
}
export interface MongoConn {
readonly host: string;
readonly port: number;
readonly user: string;
readonly password: string;
/** The database the admin user authenticates against — `admin` for the root user. */
readonly authSource: string;
}
export class MongoClient {
constructor(private readonly conn: MongoConn) {}
/**
* Build from the module's resolved environment. Reads MESH_MONGODB_* first (the documented names),
* falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner container.
* Throws if it cannot find a host and an admin password.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): MongoClient {
const url = env.MESH_PROVISION_MONGODB ? safeUrl(env.MESH_PROVISION_MONGODB) : undefined;
const host = env.MESH_MONGODB_HOST ?? url?.hostname;
const port = Number(env.MESH_MONGODB_PORT ?? url?.port ?? "27017") || 27017;
const user = env.MESH_MONGODB_USER ?? (url?.username ? decodeURIComponent(url.username) : "root");
const authSource =
env.MESH_MONGODB_AUTHSOURCE ?? url?.searchParams.get("authSource") ?? "admin";
const password = env.MESH_MONGODB_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE);
if (!host || !password) {
throw new Error("mongodb host or admin password is not set — mongodb's own code cannot reach the server");
}
return new MongoClient({ host, port, user, password, authSource });
}
get host(): string {
return this.conn.host;
}
get port(): number {
return this.conn.port;
}
/** 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}&directConnection=true`;
}
/**
* 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.
*/
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();
}
}
/**
* Create a login user and the database it owns, idempotently. The user is created inside the
* target database with the `dbOwner` role scoped to that database, so the consumer owns exactly
* its own and authenticates with the target database as its authSource. Re-running updates the
* password and roles, so a rotated credential converges.
*/
async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> {
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 });
}
});
}
/**
* Whether `user` authenticates against `database` with exactly `password` and holds `dbOwner`
* there: checked by connecting as the consumer, the way it connects. Read-only. `false` only on an
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
// 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 {
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) {
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();
}
}
/** 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. */
/** Withdraw a consumer and keep its database: the user keeps its name and loses every role. */
async lockUser(database: string, user: string): Promise<void> {
await this.admin(async (client) => {
const target = client.db(database);
try {
await target.command({ updateUser: user, roles: [] });
} catch (err) {
if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound
}
});
}
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
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.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));
}
/**
* 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,
collection: string,
filter: Readonly<Record<string, unknown>>,
limit: number,
): Promise<Record<string, unknown>[]> {
const capped = Math.max(1, Math.min(limit, 1000));
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");
}
function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined;
try {
return readFileSync(path, "utf8").trim();
} catch {
return undefined;
}
}
function safeUrl(raw: string): URL | undefined {
try {
return new URL(raw);
} catch {
return undefined;
}
}