Tienson Qin 3 недель назад
Родитель
Сommit
b137eeedea

+ 12 - 0
deps/db-sync/README.md

@@ -59,6 +59,18 @@ before it calls the worker delete endpoint for each graph. Set
 `DB_SYNC_BASE_URL` and `DB_SYNC_ADMIN_TOKEN` or pass `--base-url` and
 `--admin-token` when running it.
 
+Delete a user completely (owned graphs, memberships, keys, and user row):
+
+```bash
+cd deps/db-sync
+yarn delete-user-totally --username alice
+yarn delete-user-totally --user-id us-east-1:example-user-id
+```
+
+The script prints all linked graphs first, deletes owned graphs through the
+admin graph delete endpoint, then removes the user's remaining D1 references.
+It requires typing `DELETE` as confirmation.
+
 ### Node.js Adapter (self-hosted)
 
 Build the adapter:

+ 1 - 0
deps/db-sync/package.json

@@ -7,6 +7,7 @@
     "watch": "clojure -M:cljs watch db-sync",
     "release": "clojure -M:cljs release db-sync",
     "delete-graphs-for-user": "node worker/scripts/delete_graphs_for_user.js",
+    "delete-user-totally": "node worker/scripts/delete_user_totally.js",
     "show-graphs-for-user": "node worker/scripts/show_graphs_for_user.js",
     "build:node-adapter": "clojure -M:cljs release db-sync-node",
     "dev:node-adapter": "clojure -M:cljs watch db-sync-node",

+ 29 - 0
deps/db-sync/worker/scripts/delete_graphs_for_user.js

@@ -16,6 +16,33 @@ const {
   runWranglerQuery,
 } = require("./graph_user_lib");
 
+function escapeSqlValue(value) {
+  return value.replaceAll("'", "''");
+}
+
+function ensureMutationSuccess(output, context) {
+  if (!Array.isArray(output) || output.length === 0) {
+    fail(`Unexpected empty response from wrangler while ${context}.`);
+  }
+
+  output.forEach((statement, index) => {
+    if (!statement.success) {
+      fail(`Wrangler mutation failed while ${context} (statement ${index + 1}).`);
+    }
+  });
+}
+
+function deleteGraphAesKeys(options, graphId) {
+  const sql = `delete from graph_aes_keys where graph_id = '${escapeSqlValue(graphId)}'`;
+  const wranglerArgs = buildWranglerArgs({
+    database: options.database,
+    config: options.config,
+    env: options.env,
+    sql,
+  });
+  ensureMutationSuccess(runWranglerQuery(wranglerArgs), `deleting graph_aes_keys for ${graphId}`);
+}
+
 function printHelp() {
   console.log(`Delete db-sync graphs owned by a user from a remote D1 environment.
 
@@ -134,6 +161,8 @@ async function main() {
       const body = await response.text();
       fail(`Delete failed for ${graph.graph_id}: ${response.status} ${body}`);
     }
+
+    deleteGraphAesKeys(options, graph.graph_id);
   }
 
   console.log(`Deleted ${result.graphs.length} owned graph(s).`);

+ 254 - 0
deps/db-sync/worker/scripts/delete_user_totally.js

@@ -0,0 +1,254 @@
+#!/usr/bin/env node
+
+const path = require("node:path");
+const readline = require("node:readline/promises");
+const { stdin, stdout } = require("node:process");
+const { parseArgs } = require("node:util");
+const {
+  buildAdminGraphDeleteUrl,
+  buildUserGraphsSql,
+  buildWranglerArgs,
+  defaultConfigPath,
+  fail,
+  formatUserGraphsResult,
+  parseWranglerResults,
+  printUserGraphsTable,
+  runWranglerQuery,
+} = require("./graph_user_lib");
+
+function printHelp() {
+  console.log(`Delete a db-sync user and all related data from a remote D1 environment.
+
+Usage:
+  node worker/scripts/delete_user_totally.js --username <username> [--env prod]
+  node worker/scripts/delete_user_totally.js --user-id <user-id> [--env prod]
+
+Options:
+  --username <username>  Look up the target user by username.
+  --user-id <user-id>    Look up the target user by user id.
+  --env <env>            Wrangler environment to use. Defaults to "prod".
+  --database <name>      D1 binding or database name. Defaults to "DB".
+  --config <path>        Wrangler config path. Defaults to worker/wrangler.toml.
+  --base-url <url>       Worker base URL. Defaults to DB_SYNC_BASE_URL.
+  --admin-token <token>  Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN.
+  --help                 Show this message.
+`);
+}
+
+function parseCliArgs(argv) {
+  const { values } = parseArgs({
+    args: argv,
+    options: {
+      username: { type: "string" },
+      "user-id": { type: "string" },
+      env: { type: "string", default: "prod" },
+      database: { type: "string", default: "DB" },
+      config: { type: "string", default: defaultConfigPath },
+      "base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL },
+      "admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
+      help: { type: "boolean", default: false },
+    },
+    strict: true,
+    allowPositionals: false,
+  });
+
+  if (values.help) {
+    printHelp();
+    process.exit(0);
+  }
+
+  const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
+  if (lookupCount !== 1) {
+    fail("Pass exactly one of --username or --user-id.");
+  }
+
+  return {
+    lookupField: values.username ? "username" : "id",
+    lookupLabel: values.username ? "username" : "user-id",
+    lookupValue: values.username ?? values["user-id"],
+    env: values.env,
+    database: values.database,
+    config: path.resolve(values.config),
+    baseUrl: values["base-url"],
+    adminToken: values["admin-token"],
+  };
+}
+
+function escapeSqlValue(value) {
+  return value.replaceAll("'", "''");
+}
+
+function runSelectQuery(options, sql) {
+  const wranglerArgs = buildWranglerArgs({
+    database: options.database,
+    config: options.config,
+    env: options.env,
+    sql,
+  });
+
+  return parseWranglerResults(runWranglerQuery(wranglerArgs));
+}
+
+function runMutationQuery(options, sql) {
+  const wranglerArgs = buildWranglerArgs({
+    database: options.database,
+    config: options.config,
+    env: options.env,
+    sql,
+  });
+
+  const output = runWranglerQuery(wranglerArgs);
+  if (!Array.isArray(output) || output.length === 0) {
+    throw new Error("Unexpected empty response from wrangler.");
+  }
+
+  output.forEach((statement, index) => {
+    if (!statement.success) {
+      throw new Error(`Wrangler reported an unsuccessful mutation (statement ${index + 1}).`);
+    }
+  });
+
+  return output.reduce((sum, statement) => sum + Number(statement?.meta?.changes ?? 0), 0);
+}
+
+function sqlCountToNumber(value) {
+  const numericValue = Number(value);
+  return Number.isFinite(numericValue) ? numericValue : 0;
+}
+
+function isDeleteConfirmationAccepted(answer, userId) {
+  const normalizedAnswer = answer.trim();
+  return normalizedAnswer === "DELETE" || normalizedAnswer === `DELETE USER ${userId}`;
+}
+
+async function confirmDeletion({ user, ownedGraphsCount, memberGraphsCount }) {
+  const rl = readline.createInterface({ input: stdin, output: stdout });
+  try {
+    const answer = await rl.question(
+      `Type DELETE to permanently delete this user (${user.user_id}; ${ownedGraphsCount} owned graph(s), ${memberGraphsCount} membership(s)): `,
+    );
+    return isDeleteConfirmationAccepted(answer, user.user_id);
+  } finally {
+    rl.close();
+  }
+}
+
+async function deleteOwnedGraphs(options, ownedGraphs) {
+  for (const graph of ownedGraphs) {
+    const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), {
+      method: "DELETE",
+      headers: {
+        "x-db-sync-admin-token": options.adminToken,
+      },
+    });
+
+    if (!response.ok) {
+      const payload = await response.text();
+      fail(`Delete failed for owned graph ${graph.graph_id}: ${response.status} ${payload}`);
+    }
+  }
+}
+
+async function main() {
+  const options = parseCliArgs(process.argv.slice(2));
+  const graphRows = runSelectQuery(options, buildUserGraphsSql({ ...options, ownedOnly: false }));
+  const result = formatUserGraphsResult(graphRows);
+
+  if (!result) {
+    fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
+  }
+
+  const ownedGraphs = result.graphs.filter((graph) => graph.access_role === "owner");
+  const memberGraphs = result.graphs.filter((graph) => graph.access_role !== "owner");
+
+  printUserGraphsTable(result, "Graphs linked to user");
+  console.log(`Owned graphs: ${ownedGraphs.length}`);
+  console.log(`Member graphs: ${memberGraphs.length}`);
+
+  if (ownedGraphs.length > 0 && !options.baseUrl) {
+    fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL.");
+  }
+
+  if (ownedGraphs.length > 0 && !options.adminToken) {
+    fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
+  }
+
+  const confirmed = await confirmDeletion({
+    user: result.user,
+    ownedGraphsCount: ownedGraphs.length,
+    memberGraphsCount: memberGraphs.length,
+  });
+
+  if (!confirmed) {
+    console.log("Aborted.");
+    return;
+  }
+
+  if (ownedGraphs.length > 0) {
+    await deleteOwnedGraphs(options, ownedGraphs);
+  }
+
+  const escapedUserId = escapeSqlValue(result.user.user_id);
+  const remainingOwnedGraphRows = runSelectQuery(
+    options,
+    `select count(1) as owned_graph_count from graphs where user_id = '${escapedUserId}'`,
+  );
+  const remainingOwnedGraphCount = sqlCountToNumber(remainingOwnedGraphRows[0]?.owned_graph_count);
+  if (remainingOwnedGraphCount > 0) {
+    fail(
+      `Owned graph cleanup incomplete: ${remainingOwnedGraphCount} graph(s) still owned by ${result.user.user_id}.`,
+    );
+  }
+
+  const deletedGraphAesKeys = runMutationQuery(
+    options,
+    `delete from graph_aes_keys where user_id = '${escapedUserId}'`,
+  );
+  const deletedGraphMembers = runMutationQuery(
+    options,
+    `delete from graph_members where user_id = '${escapedUserId}'`,
+  );
+  const clearedInvitedBy = runMutationQuery(
+    options,
+    `update graph_members set invited_by = null where invited_by = '${escapedUserId}'`,
+  );
+  const deletedUserRsaKeys = runMutationQuery(
+    options,
+    `delete from user_rsa_keys where user_id = '${escapedUserId}'`,
+  );
+  const deletedUsers = runMutationQuery(options, `delete from users where id = '${escapedUserId}'`);
+
+  if (deletedUsers !== 1) {
+    fail(`Expected to delete exactly one user row, but deleted ${deletedUsers}.`);
+  }
+
+  const userRowsAfterDelete = runSelectQuery(
+    options,
+    `select id from users where id = '${escapedUserId}' limit 1`,
+  );
+  if (userRowsAfterDelete.length > 0) {
+    fail(`User ${result.user.user_id} still exists after deletion.`);
+  }
+
+  console.table([
+    { step: "owned graphs deleted", rows: ownedGraphs.length },
+    { step: "graph_aes_keys deleted", rows: deletedGraphAesKeys },
+    { step: "graph_members deleted", rows: deletedGraphMembers },
+    { step: "graph_members invited_by cleared", rows: clearedInvitedBy },
+    { step: "user_rsa_keys deleted", rows: deletedUserRsaKeys },
+    { step: "users deleted", rows: deletedUsers },
+  ]);
+  console.log(`Deleted user ${result.user.user_id} successfully.`);
+}
+
+if (require.main === module) {
+  main().catch((error) => {
+    fail(error instanceof Error ? error.message : String(error));
+  });
+}
+
+module.exports = {
+  confirmDeletion,
+  isDeleteConfirmationAccepted,
+  parseCliArgs,
+};

+ 59 - 0
deps/db-sync/worker/scripts/delete_user_totally.test.js

@@ -0,0 +1,59 @@
+const assert = require("node:assert/strict");
+const { spawnSync } = require("node:child_process");
+const path = require("node:path");
+const test = require("node:test");
+
+const { isDeleteConfirmationAccepted, parseCliArgs } = require("./delete_user_totally");
+const { defaultConfigPath } = require("./graph_user_lib");
+
+function runCli(args) {
+  return spawnSync(process.execPath, [path.join(__dirname, "delete_user_totally.js"), ...args], {
+    encoding: "utf8",
+  });
+}
+
+test("parseCliArgs accepts --username", () => {
+  const parsed = parseCliArgs(["--username", "alice"]);
+
+  assert.equal(parsed.lookupField, "username");
+  assert.equal(parsed.lookupLabel, "username");
+  assert.equal(parsed.lookupValue, "alice");
+  assert.equal(parsed.env, "prod");
+  assert.equal(parsed.database, "DB");
+  assert.equal(parsed.config, path.resolve(defaultConfigPath));
+});
+
+test("parseCliArgs accepts --user-id", () => {
+  const parsed = parseCliArgs(["--user-id", "user-123"]);
+
+  assert.equal(parsed.lookupField, "id");
+  assert.equal(parsed.lookupLabel, "user-id");
+  assert.equal(parsed.lookupValue, "user-123");
+});
+
+test("CLI --help exits successfully", () => {
+  const result = runCli(["--help"]);
+
+  assert.equal(result.status, 0);
+  assert.match(result.stdout, /Delete a db-sync user and all related data/);
+});
+
+test("CLI rejects passing both --username and --user-id", () => {
+  const result = runCli(["--username", "alice", "--user-id", "user-123"]);
+
+  assert.equal(result.status, 1);
+  assert.match(result.stderr, /Pass exactly one of --username or --user-id\./);
+});
+
+test("confirmation accepts DELETE", () => {
+  assert.equal(isDeleteConfirmationAccepted("DELETE", "user-123"), true);
+});
+
+test("confirmation accepts legacy DELETE USER <id>", () => {
+  assert.equal(isDeleteConfirmationAccepted("DELETE USER user-123", "user-123"), true);
+});
+
+test("confirmation rejects unrelated input", () => {
+  assert.equal(isDeleteConfirmationAccepted("DELETE USER other-user", "user-123"), false);
+  assert.equal(isDeleteConfirmationAccepted("yes", "user-123"), false);
+});