Skip to content

Commit 29f97cc

Browse files
committed
Fix workspace credential sharing (#950)
Org-shared connections now resolve for every member of a workspace, not just the member who created them. Existing connections are migrated automatically.
1 parent 616e37d commit 29f97cc

3 files changed

Lines changed: 118 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**Fix credential sharing for workspace connections**
6+
7+
Org-shared connections now resolve for every member of a workspace, not only the member who created them. Existing connections are migrated automatically; stored secrets are unaffected.

apps/cloud/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
"db:migrate:prod": "op run --env-file=.env.production -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
1616
"db:migrate-auth:prod": "op run --env-file=.env.production -- bun run scripts/migrate-auth-configs.ts",
1717
"db:migrate-auth:dev": "op run --env-file=.env.op -- bun run scripts/migrate-auth-configs.ts",
18+
"db:repartition-vault:prod": "op run --env-file=.env.production -- bun run scripts/repartition-vault-metadata.ts",
19+
"db:repartition-vault:dev": "op run --env-file=.env.op -- bun run scripts/repartition-vault-metadata.ts",
1820
"db:migrate:dev": "op run --env-file=.env.op -- bun --bun ../../node_modules/.bun/node_modules/drizzle-kit/bin.cjs migrate",
1921
"build": "node scripts/build.mjs",
2022
"preview": "vite preview",
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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

Comments
 (0)