Skip to content

Commit 745deac

Browse files
committed
Unify scoped credential bindings
Move OpenAPI, GraphQL, and MCP credential ownership onto shared scoped credential bindings with explicit write targets. Add one-shot cloud/local migrations for the cutover, require explicit OAuth token scope, update source/auth UI flows to choose credential target scope, and tighten scoped usage/policy behavior with regression coverage.
1 parent 92279f2 commit 745deac

130 files changed

Lines changed: 15732 additions & 2865 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/cloud/drizzle/0009_scoped_credentials_cutover.sql‎

Lines changed: 1116 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/cloud/drizzle/meta/0009_snapshot.json‎

Lines changed: 2468 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/cloud/drizzle/meta/_journal.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
"when": 1778004191000,
6565
"tag": "0008_normalize_plugin_secret_refs",
6666
"breakpoints": true
67+
},
68+
{
69+
"idx": 9,
70+
"version": "7",
71+
"when": 1778128200000,
72+
"tag": "0009_scoped_credentials_cutover",
73+
"breakpoints": true
6774
}
6875
]
6976
}

‎apps/cloud/scripts/dev-db.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import { PGlite } from "@electric-sql/pglite";
1010
import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
11+
import { execSync } from "node:child_process";
12+
import { setTimeout as sleep } from "node:timers/promises";
1113
import { drizzle } from "drizzle-orm/pglite";
1214
import { migrate } from "drizzle-orm/pglite/migrator";
1315
import { resolve, dirname } from "node:path";
@@ -18,6 +20,36 @@ const PORT = 5433;
1820
const DB_PATH = resolve(__dirname, "../.dev-db");
1921
const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle");
2022

23+
// Reap any orphan dev-db from a previous `bun dev` that didn't shut down
24+
// cleanly — otherwise the new instance can't bind to PORT and the app ends
25+
// up talking to a stale PGlite with the wrong schema.
26+
function reapStaleDevDb() {
27+
const out = execSync(`lsof -ti tcp:${PORT} -sTCP:LISTEN 2>/dev/null || true`, {
28+
encoding: "utf8",
29+
});
30+
const pids = out.trim().split("\n").filter(Boolean);
31+
if (pids.length === 0) return false;
32+
33+
for (const pid of pids) {
34+
const cmd = execSync(`ps -p ${pid} -o args= 2>/dev/null || true`, {
35+
encoding: "utf8",
36+
}).trim();
37+
if (!cmd.includes("dev-db.ts")) {
38+
console.error(`[dev-db] Port ${PORT} is held by an unexpected process (pid ${pid}): ${cmd}`);
39+
console.error(`[dev-db] Refusing to kill it. Free the port and retry.`);
40+
process.exit(1);
41+
}
42+
console.log(`[dev-db] Reaping stale dev-db (pid ${pid})`);
43+
execSync(`kill -KILL ${pid}`);
44+
}
45+
return true;
46+
}
47+
48+
if (reapStaleDevDb()) {
49+
// Give the kernel a beat to release the socket before we try to bind.
50+
await sleep(200);
51+
}
52+
2153
console.log(`[dev-db] Starting PGlite at ${DB_PATH}`);
2254
const db = await PGlite.create(DB_PATH);
2355

‎apps/cloud/src/mcp-miniflare.e2e.node.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it)
794794
// `HttpApiGroup` name ("approve") becomes part of the sandbox path,
795795
// so the invocation reads `tools.approveapi.approve.approveThing`.
796796
const code = [
797-
`await tools.openapi.addSource({ spec: ${JSON.stringify(specJson)}, namespace: "approveapi" });`,
797+
`await tools.openapi.addSource({ scope: ${JSON.stringify(orgId)}, spec: ${JSON.stringify(specJson)}, namespace: "approveapi" });`,
798798
`return await tools.approveapi.approve.approveThing({});`,
799799
].join("\n");
800800
const result = yield* Effect.promise(() =>

‎apps/cloud/src/services/executor-schema.ts‎

Lines changed: 69 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
primaryKey,
1010
} from "drizzle-orm/pg-core";
1111

12+
export { blobTable as blob } from "@executor-js/storage-postgres";
13+
1214
export const source = pgTable(
1315
"source",
1416
{
@@ -134,6 +136,35 @@ export const oauth2_session = pgTable(
134136
],
135137
);
136138

139+
export const credential_binding = pgTable(
140+
"credential_binding",
141+
{
142+
id: text("id").notNull(),
143+
scope_id: text("scope_id").notNull(),
144+
plugin_id: text("plugin_id").notNull(),
145+
source_id: text("source_id").notNull(),
146+
source_scope_id: text("source_scope_id").notNull(),
147+
slot_key: text("slot_key").notNull(),
148+
kind: text("kind").notNull(),
149+
text_value: text("text_value"),
150+
secret_id: text("secret_id"),
151+
connection_id: text("connection_id"),
152+
created_at: timestamp("created_at").notNull(),
153+
updated_at: timestamp("updated_at").notNull(),
154+
},
155+
(table) => [
156+
primaryKey({ columns: [table.scope_id, table.id] }),
157+
index("credential_binding_scope_id_idx").on(table.scope_id),
158+
index("credential_binding_plugin_id_idx").on(table.plugin_id),
159+
index("credential_binding_source_id_idx").on(table.source_id),
160+
index("credential_binding_source_scope_id_idx").on(table.source_scope_id),
161+
index("credential_binding_slot_key_idx").on(table.slot_key),
162+
index("credential_binding_kind_idx").on(table.kind),
163+
index("credential_binding_secret_id_idx").on(table.secret_id),
164+
index("credential_binding_connection_id_idx").on(table.connection_id),
165+
],
166+
);
167+
137168
export const tool_policy = pgTable(
138169
"tool_policy",
139170
{
@@ -160,7 +191,6 @@ export const openapi_source = pgTable(
160191
spec: text("spec").notNull(),
161192
source_url: text("source_url"),
162193
base_url: text("base_url"),
163-
headers: jsonb("headers"),
164194
oauth2: jsonb("oauth2"),
165195
},
166196
(table) => [
@@ -184,28 +214,22 @@ export const openapi_operation = pgTable(
184214
],
185215
);
186216

187-
export const openapi_source_binding = pgTable(
188-
"openapi_source_binding",
217+
export const openapi_source_header = pgTable(
218+
"openapi_source_header",
189219
{
190-
id: text("id").primaryKey(),
220+
id: text("id").notNull(),
221+
scope_id: text("scope_id").notNull(),
191222
source_id: text("source_id").notNull(),
192-
source_scope_id: text("source_scope_id").notNull(),
193-
target_scope_id: text("target_scope_id").notNull(),
194-
slot: text("slot").notNull(),
195-
kind: text("kind", { enum: ["secret", "connection", "text"] }).notNull(),
196-
secret_id: text("secret_id"),
197-
connection_id: text("connection_id"),
223+
name: text("name").notNull(),
224+
kind: text("kind", { enum: ["text", "binding"] }).notNull(),
198225
text_value: text("text_value"),
199-
created_at: timestamp("created_at").notNull(),
200-
updated_at: timestamp("updated_at").notNull(),
226+
slot_key: text("slot_key"),
227+
prefix: text("prefix"),
201228
},
202229
(table) => [
203-
index("openapi_source_binding_source_id_idx").on(table.source_id),
204-
index("openapi_source_binding_source_scope_id_idx").on(table.source_scope_id),
205-
index("openapi_source_binding_target_scope_id_idx").on(table.target_scope_id),
206-
index("openapi_source_binding_slot_idx").on(table.slot),
207-
index("openapi_source_binding_secret_id_idx").on(table.secret_id),
208-
index("openapi_source_binding_connection_id_idx").on(table.connection_id),
230+
primaryKey({ columns: [table.scope_id, table.id] }),
231+
index("openapi_source_header_scope_id_idx").on(table.scope_id),
232+
index("openapi_source_header_source_id_idx").on(table.source_id),
209233
],
210234
);
211235

@@ -216,16 +240,15 @@ export const openapi_source_query_param = pgTable(
216240
scope_id: text("scope_id").notNull(),
217241
source_id: text("source_id").notNull(),
218242
name: text("name").notNull(),
219-
kind: text("kind", { enum: ["text", "secret"] }).notNull(),
243+
kind: text("kind", { enum: ["text", "binding"] }).notNull(),
220244
text_value: text("text_value"),
221-
secret_id: text("secret_id"),
222-
secret_prefix: text("secret_prefix"),
245+
slot_key: text("slot_key"),
246+
prefix: text("prefix"),
223247
},
224248
(table) => [
225249
primaryKey({ columns: [table.scope_id, table.id] }),
226250
index("openapi_source_query_param_scope_id_idx").on(table.scope_id),
227251
index("openapi_source_query_param_source_id_idx").on(table.source_id),
228-
index("openapi_source_query_param_secret_id_idx").on(table.secret_id),
229252
],
230253
);
231254

@@ -236,16 +259,15 @@ export const openapi_source_spec_fetch_header = pgTable(
236259
scope_id: text("scope_id").notNull(),
237260
source_id: text("source_id").notNull(),
238261
name: text("name").notNull(),
239-
kind: text("kind", { enum: ["text", "secret"] }).notNull(),
262+
kind: text("kind", { enum: ["text", "binding"] }).notNull(),
240263
text_value: text("text_value"),
241-
secret_id: text("secret_id"),
242-
secret_prefix: text("secret_prefix"),
264+
slot_key: text("slot_key"),
265+
prefix: text("prefix"),
243266
},
244267
(table) => [
245268
primaryKey({ columns: [table.scope_id, table.id] }),
246269
index("openapi_source_spec_fetch_header_scope_id_idx").on(table.scope_id),
247270
index("openapi_source_spec_fetch_header_source_id_idx").on(table.source_id),
248-
index("openapi_source_spec_fetch_header_secret_id_idx").on(table.secret_id),
249271
],
250272
);
251273

@@ -256,16 +278,15 @@ export const openapi_source_spec_fetch_query_param = pgTable(
256278
scope_id: text("scope_id").notNull(),
257279
source_id: text("source_id").notNull(),
258280
name: text("name").notNull(),
259-
kind: text("kind", { enum: ["text", "secret"] }).notNull(),
281+
kind: text("kind", { enum: ["text", "binding"] }).notNull(),
260282
text_value: text("text_value"),
261-
secret_id: text("secret_id"),
262-
secret_prefix: text("secret_prefix"),
283+
slot_key: text("slot_key"),
284+
prefix: text("prefix"),
263285
},
264286
(table) => [
265287
primaryKey({ columns: [table.scope_id, table.id] }),
266288
index("openapi_source_spec_fetch_query_param_scope_id_idx").on(table.scope_id),
267289
index("openapi_source_spec_fetch_query_param_source_id_idx").on(table.source_id),
268-
index("openapi_source_spec_fetch_query_param_secret_id_idx").on(table.secret_id),
269290
],
270291
);
271292

@@ -280,20 +301,16 @@ export const mcp_source = pgTable(
280301
.default("none")
281302
.notNull(),
282303
auth_header_name: text("auth_header_name"),
283-
auth_secret_id: text("auth_secret_id"),
284-
auth_secret_prefix: text("auth_secret_prefix"),
285-
auth_connection_id: text("auth_connection_id"),
286-
auth_client_id_secret_id: text("auth_client_id_secret_id"),
287-
auth_client_secret_secret_id: text("auth_client_secret_secret_id"),
304+
auth_header_slot: text("auth_header_slot"),
305+
auth_header_prefix: text("auth_header_prefix"),
306+
auth_connection_slot: text("auth_connection_slot"),
307+
auth_client_id_slot: text("auth_client_id_slot"),
308+
auth_client_secret_slot: text("auth_client_secret_slot"),
288309
created_at: timestamp("created_at").notNull(),
289310
},
290311
(table) => [
291312
primaryKey({ columns: [table.scope_id, table.id] }),
292313
index("mcp_source_scope_id_idx").on(table.scope_id),
293-
index("mcp_source_auth_secret_id_idx").on(table.auth_secret_id),
294-
index("mcp_source_auth_connection_id_idx").on(table.auth_connection_id),
295-
index("mcp_source_auth_client_id_secret_id_idx").on(table.auth_client_id_secret_id),
296-
index("mcp_source_auth_client_secret_secret_id_idx").on(table.auth_client_secret_secret_id),
297314
],
298315
);
299316

@@ -304,16 +321,15 @@ export const mcp_source_header = pgTable(
304321
scope_id: text("scope_id").notNull(),
305322
source_id: text("source_id").notNull(),
306323
name: text("name").notNull(),
307-
kind: text("kind", { enum: ["text", "secret"] }).notNull(),
324+
kind: text("kind", { enum: ["text", "binding"] }).notNull(),
308325
text_value: text("text_value"),
309-
secret_id: text("secret_id"),
310-
secret_prefix: text("secret_prefix"),
326+
slot_key: text("slot_key"),
327+
prefix: text("prefix"),
311328
},
312329
(table) => [
313330
primaryKey({ columns: [table.scope_id, table.id] }),
314331
index("mcp_source_header_scope_id_idx").on(table.scope_id),
315332
index("mcp_source_header_source_id_idx").on(table.source_id),
316-
index("mcp_source_header_secret_id_idx").on(table.secret_id),
317333
],
318334
);
319335

@@ -324,16 +340,15 @@ export const mcp_source_query_param = pgTable(
324340
scope_id: text("scope_id").notNull(),
325341
source_id: text("source_id").notNull(),
326342
name: text("name").notNull(),
327-
kind: text("kind", { enum: ["text", "secret"] }).notNull(),
343+
kind: text("kind", { enum: ["text", "binding"] }).notNull(),
328344
text_value: text("text_value"),
329-
secret_id: text("secret_id"),
330-
secret_prefix: text("secret_prefix"),
345+
slot_key: text("slot_key"),
346+
prefix: text("prefix"),
331347
},
332348
(table) => [
333349
primaryKey({ columns: [table.scope_id, table.id] }),
334350
index("mcp_source_query_param_scope_id_idx").on(table.scope_id),
335351
index("mcp_source_query_param_source_id_idx").on(table.source_id),
336-
index("mcp_source_query_param_secret_id_idx").on(table.secret_id),
337352
],
338353
);
339354

@@ -363,12 +378,11 @@ export const graphql_source = pgTable(
363378
auth_kind: text("auth_kind", { enum: ["none", "oauth2"] })
364379
.default("none")
365380
.notNull(),
366-
auth_connection_id: text("auth_connection_id"),
381+
auth_connection_slot: text("auth_connection_slot"),
367382
},
368383
(table) => [
369384
primaryKey({ columns: [table.scope_id, table.id] }),
370385
index("graphql_source_scope_id_idx").on(table.scope_id),
371-
index("graphql_source_auth_connection_id_idx").on(table.auth_connection_id),
372386
],
373387
);
374388

@@ -379,16 +393,15 @@ export const graphql_source_header = pgTable(
379393
scope_id: text("scope_id").notNull(),
380394
source_id: text("source_id").notNull(),
381395
name: text("name").notNull(),
382-
kind: text("kind", { enum: ["text", "secret"] }).notNull(),
396+
kind: text("kind", { enum: ["text", "binding"] }).notNull(),
383397
text_value: text("text_value"),
384-
secret_id: text("secret_id"),
385-
secret_prefix: text("secret_prefix"),
398+
slot_key: text("slot_key"),
399+
prefix: text("prefix"),
386400
},
387401
(table) => [
388402
primaryKey({ columns: [table.scope_id, table.id] }),
389403
index("graphql_source_header_scope_id_idx").on(table.scope_id),
390404
index("graphql_source_header_source_id_idx").on(table.source_id),
391-
index("graphql_source_header_secret_id_idx").on(table.secret_id),
392405
],
393406
);
394407

@@ -399,16 +412,15 @@ export const graphql_source_query_param = pgTable(
399412
scope_id: text("scope_id").notNull(),
400413
source_id: text("source_id").notNull(),
401414
name: text("name").notNull(),
402-
kind: text("kind", { enum: ["text", "secret"] }).notNull(),
415+
kind: text("kind", { enum: ["text", "binding"] }).notNull(),
403416
text_value: text("text_value"),
404-
secret_id: text("secret_id"),
405-
secret_prefix: text("secret_prefix"),
417+
slot_key: text("slot_key"),
418+
prefix: text("prefix"),
406419
},
407420
(table) => [
408421
primaryKey({ columns: [table.scope_id, table.id] }),
409422
index("graphql_source_query_param_scope_id_idx").on(table.scope_id),
410423
index("graphql_source_query_param_source_id_idx").on(table.source_id),
411-
index("graphql_source_query_param_secret_id_idx").on(table.secret_id),
412424
],
413425
);
414426

@@ -441,13 +453,3 @@ export const workos_vault_metadata = pgTable(
441453
index("workos_vault_metadata_scope_id_idx").on(table.scope_id),
442454
],
443455
);
444-
445-
export const blob = pgTable(
446-
"blob",
447-
{
448-
namespace: text("namespace").notNull(),
449-
key: text("key").notNull(),
450-
value: text("value").notNull(),
451-
},
452-
(table) => [primaryKey({ columns: [table.namespace, table.key] })],
453-
);

‎apps/cloud/src/services/executor.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ const orgPlugins = (): CloudPlugins =>
4343
// distinct scope row; future workspace scopes can slot in between without
4444
// conflicting with a hypothetical global user scope.
4545
//
46-
// OAuth tokens land at `ctx.scopes[0]` (the user-org scope) by default, so
47-
// a member's access/refresh tokens can't leak to other members via
48-
// `secrets.list`, while source rows and org-wide credentials live on the
49-
// outer scope.
46+
// OAuth token writes require an explicit `tokenScope`. User sign-in UI passes
47+
// the user-org scope so a member's access/refresh tokens cannot leak to other
48+
// members via `secrets.list`, while source rows and org-wide credentials live
49+
// on the outer scope.
5050
// ---------------------------------------------------------------------------
5151

5252
export const createScopedExecutor = (

‎apps/cloud/src/services/mcp-oauth.node.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => {
280280
endpoint: `${fake.url}/mcp`,
281281
redirectUrl,
282282
connectionId,
283+
tokenScope: String(userScope),
283284
strategy: { kind: "dynamic-dcr" },
284285
pluginId: "mcp",
285286
},
@@ -328,6 +329,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => {
328329
endpoint,
329330
redirectUrl,
330331
connectionId,
332+
tokenScope: String(scopeA),
331333
strategy: { kind: "dynamic-dcr" },
332334
pluginId: "mcp",
333335
},
@@ -351,6 +353,7 @@ describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => {
351353
endpoint,
352354
redirectUrl,
353355
connectionId,
356+
tokenScope: String(scopeB),
354357
strategy: { kind: "dynamic-dcr" },
355358
pluginId: "mcp",
356359
},

0 commit comments

Comments
 (0)