|
| 1 | +/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band migration script over a raw postgres connection */ |
| 2 | +// --------------------------------------------------------------------------- |
| 3 | +// One-off data migration: re-file mis-partitioned WorkOS Vault metadata rows. |
| 4 | +// |
| 5 | +// A bug in the v1.5 vault provider (`ownerOf(binding)`) filed every credential |
| 6 | +// created by a bound user — including ORG-shared connections — under that |
| 7 | +// user's private partition. Org-shared credentials then resolved only for |
| 8 | +// whoever pasted them; every other org member got `connection_value_missing`. |
| 9 | +// |
| 10 | +// The fix (secret-store.ts: `ownerForItemId`) files by the owner embedded in |
| 11 | +// the item id. This script repairs the rows already written wrong: an item id |
| 12 | +// of `connection:org:…` / `oauth:org:…` / `oauth-client:org:…` whose metadata |
| 13 | +// row sits at owner='user' is moved to owner='org', subject=''. The Vault |
| 14 | +// object itself is untouched (flat context) — only the metadata pointer moves. |
| 15 | +// |
| 16 | +// bun run db:repartition-vault:prod # op run --env-file=.env.production |
| 17 | +// bun run db:repartition-vault:dev # against the local PGlite dev db |
| 18 | +// |
| 19 | +// Idempotent — already-correct rows are skipped. Pass --dry-run to print the |
| 20 | +// plan without writing. |
| 21 | +// --------------------------------------------------------------------------- |
| 22 | + |
| 23 | +import postgres from "postgres"; |
| 24 | + |
| 25 | +const VAULT_PLUGIN_ID = "workosVault"; |
| 26 | +const METADATA_COLLECTION = "metadata"; |
| 27 | +// Item-id prefixes whose second colon-segment is the owning partition. |
| 28 | +const OWNER_SCOPED_PREFIXES = ["connection", "oauth", "oauth-client"]; |
| 29 | + |
| 30 | +const connectionString = process.env.DATABASE_URL; |
| 31 | +if (!connectionString) { |
| 32 | + console.error("DATABASE_URL is not set"); |
| 33 | + process.exit(1); |
| 34 | +} |
| 35 | +const dryRun = process.argv.includes("--dry-run"); |
| 36 | + |
| 37 | +// Direct (non-Hyperdrive) connection — PlanetScale requires TLS. |
| 38 | +const sql = postgres(connectionString, { max: 1, prepare: false, ssl: "require" }); |
| 39 | + |
| 40 | +type Row = { |
| 41 | + row_id: string; |
| 42 | + tenant: string; |
| 43 | + owner: string; |
| 44 | + subject: string; |
| 45 | + key: string; |
| 46 | +}; |
| 47 | + |
| 48 | +const embeddedOwner = (key: string): "org" | "user" | null => { |
| 49 | + const [prefix, owner] = key.split(":"); |
| 50 | + if (!OWNER_SCOPED_PREFIXES.includes(prefix ?? "")) return null; |
| 51 | + return owner === "org" || owner === "user" ? owner : null; |
| 52 | +}; |
| 53 | + |
| 54 | +try { |
| 55 | + const rows = await sql<Row[]>` |
| 56 | + SELECT row_id, tenant, owner, subject, key |
| 57 | + FROM plugin_storage |
| 58 | + WHERE plugin_id = ${VAULT_PLUGIN_ID} AND collection = ${METADATA_COLLECTION} |
| 59 | + `; |
| 60 | + |
| 61 | + // A row is mis-filed when its stored partition disagrees with the owner |
| 62 | + // embedded in its item id. In practice only org credentials stuck in a user |
| 63 | + // partition, but compute it generally and symmetrically. |
| 64 | + const misfiled = rows.filter((row) => { |
| 65 | + const want = embeddedOwner(row.key); |
| 66 | + if (want === null) return false; |
| 67 | + const wantSubject = want === "org" ? "" : row.subject; |
| 68 | + return row.owner !== want || row.subject !== wantSubject; |
| 69 | + }); |
| 70 | + |
| 71 | + console.log(`${rows.length} vault metadata row(s), ${misfiled.length} mis-partitioned`); |
| 72 | + const byPrefix = new Map<string, number>(); |
| 73 | + for (const row of misfiled) { |
| 74 | + const prefix = row.key.split(":")[0] ?? "?"; |
| 75 | + byPrefix.set(prefix, (byPrefix.get(prefix) ?? 0) + 1); |
| 76 | + } |
| 77 | + for (const [prefix, n] of byPrefix) console.log(` ${prefix}: ${n}`); |
| 78 | + |
| 79 | + if (dryRun) { |
| 80 | + for (const row of misfiled) { |
| 81 | + console.log( |
| 82 | + ` would move ${row.owner}/${row.subject || "''"} → ${embeddedOwner(row.key)} : ${row.key}`, |
| 83 | + ); |
| 84 | + } |
| 85 | + } else if (misfiled.length > 0) { |
| 86 | + let moved = 0; |
| 87 | + await sql.begin(async (tx) => { |
| 88 | + for (const row of misfiled) { |
| 89 | + const want = embeddedOwner(row.key)!; |
| 90 | + const wantSubject = want === "org" ? "" : row.subject; |
| 91 | + // Re-file in place: copy the data into the correct partition (no-op if a |
| 92 | + // post-fix write already created it), then drop the mis-filed row. |
| 93 | + await tx` |
| 94 | + INSERT INTO plugin_storage |
| 95 | + (row_id, tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at) |
| 96 | + SELECT row_id || '-repart', tenant, ${want}, ${wantSubject}, |
| 97 | + plugin_id, collection, key, data, created_at, now() |
| 98 | + FROM plugin_storage WHERE row_id = ${row.row_id} |
| 99 | + ON CONFLICT (tenant, owner, subject, plugin_id, collection, key) DO NOTHING |
| 100 | + `; |
| 101 | + await tx`DELETE FROM plugin_storage WHERE row_id = ${row.row_id}`; |
| 102 | + moved += 1; |
| 103 | + } |
| 104 | + }); |
| 105 | + console.log(`re-filed ${moved} row(s)`); |
| 106 | + } |
| 107 | +} finally { |
| 108 | + await sql.end(); |
| 109 | +} |
0 commit comments