History

fix(v2): require user-bound runtime context for tool credentials

Make MCP and runtime callbacks run with mandatory account identity and propagate it through task runs into source credential resolution. Tighten control-plane auth defaults and sanitize proxied response headers for safer direct Convex access.

Rhys Sullivan committed 8 months ago

Commit bb5e3e01 parent 1db806a11 files · +123 −84Browse files

Unified · Split

11 files
  1. apps/convex/convex/_generated/api.d.ts +2 −0
  2. apps/convex/convex/auth.config.ts +0 −1
  3. apps/convex/convex/control_plane/actor.ts +33 −3
  4. apps/convex/convex/executor.test.ts +1 −0
  5. apps/convex/convex/mcp.ts +30 −43
  6. apps/convex/convex/runtimeCallbacks.ts +10 −5
  7. apps/convex/convex/source_tool_registry.ts +10 −16
  8. apps/convex/convex/task_runs.ts +9 −4
  9. apps/web/app/api/control-plane/[...path]/route.ts +21 −1
  10. apps/web/next.config.ts +6 −10
  11. packages/schema/src/models/task-run.ts +1 −1
apps/convex/convex/_generated/api.d.ts+2−0
@@ -26,6 +26,7 @@ import type * as control_plane_workspaces from "../control_plane/workspaces.js"; · 25 unmodified lines
2626 import type * as credential_crypto from "../credential_crypto.js";
2727 import type * as executor from "../executor.js";
2828 import type * as http from "../http.js";
29+import type * as http_security from "../http_security.js";
2930 import type * as mcp from "../mcp.js";
3031 import type * as mcp_auth from "../mcp_auth.js";
3132 import type * as run_executor from "../run_executor.js";
@@ -61,6 +62,7 @@ declare const fullApi: ApiFromModules<{ · 29 unmodified lines
6162 credential_crypto: typeof credential_crypto;
6263 executor: typeof executor;
6364 http: typeof http;
65+ http_security: typeof http_security;
6466 mcp: typeof mcp;
6567 mcp_auth: typeof mcp_auth;
6668 run_executor: typeof run_executor;
apps/convex/convex/auth.config.ts+0−1
@@ -21,7 +21,6 @@ const providers: AuthConfig["providers"] = clientId · 20 unmodified lines
2121 type: "customJwt",
2222 issuer: `https://api.workos.com/user_management/${clientId}`,
2323 algorithm: "RS256",
24− applicationID: clientId,
2524 jwks: `https://api.workos.com/sso/jwks/${clientId}`,
2625 },
2726 ]
apps/convex/convex/control_plane/actor.ts+33−3
@@ -294,7 +294,7 @@ export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => · 293 unmodified lines
294294
295295 const principal = yield* resolveAuthenticatedPrincipal(ctx);
296296
297− const organizationMemberships = yield* runQueryEffect(
297+ let organizationMemberships = yield* runQueryEffect(
298298 "organizationMembership.list",
299299 () =>
300300 ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, {
@@ -302,17 +302,47 @@ export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => · 1 unmodified line
302302 }),
303303 );
304304
305− const organizationIds = organizationMemberships.map(
305+ let organizationIds = organizationMemberships.map(
306306 (membership) => membership.organizationId,
307307 );
308308
309− const workspaces = yield* runQueryEffect("workspace.list", () =>
309+ let workspaces = yield* runQueryEffect("workspace.list", () =>
310310 ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, {
311311 accountId: principal.accountId,
312312 organizationIds,
313313 }),
314314 );
315315
316+ if (organizationMemberships.length === 0) {
317+ const workspaceId = workspaces[0]?.id ?? `ws_${principal.accountId}`;
318+
319+ yield* runQueryEffect("workspace.ensureDefault", () =>
320+ ctx.runMutation(internal.control_plane.actor.ensureWorkspaceForActor, {
321+ workspaceId,
322+ accountId: principal.accountId,
323+ }),
324+ );
325+
326+ organizationMemberships = yield* runQueryEffect(
327+ "organizationMembership.list",
328+ () =>
329+ ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, {
330+ accountId: principal.accountId,
331+ }),
332+ );
333+
334+ organizationIds = organizationMemberships.map(
335+ (membership) => membership.organizationId,
336+ );
337+
338+ workspaces = yield* runQueryEffect("workspace.list", () =>
339+ ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, {
340+ accountId: principal.accountId,
341+ organizationIds,
342+ }),
343+ );
344+ }
345+
316346 const workspaceMemberships = workspaces.flatMap((workspace) =>
317347 deriveWorkspaceMembershipsForPrincipal({
318348 principalAccountId: principal.accountId,
apps/convex/convex/executor.test.ts+1−0
@@ -1410,6 +1410,7 @@ describe("Convex executor and control-plane", () => { · 1409 unmodified lines
14101410 t.mutation(runtimeInternal.task_runs.startTaskRun, {
14111411 workspaceId: "ws_1",
14121412 runId: "run_approval_1",
1413+ accountId: "acct_1",
14131414 }),
14141415 );
14151416
apps/convex/convex/mcp.ts+30−43
@@ -19,19 +19,11 @@ import { · 18 unmodified lines
1919 } from "./mcp_auth";
2020 import { createConvexSourceToolRegistry } from "./source_tool_registry";
2121
22−const readConfiguredWorkspaceId = (value: string | undefined): string => {
23− const normalized = value?.trim();
24− return normalized && normalized.length > 0 ? normalized : "ws_local";
25−};
26−
2722 const readWorkspaceIdFromRequest = (request: Request): string | null => {
2823 const value = new URL(request.url).searchParams.get("workspaceId")?.trim();
2924 return value && value.length > 0 ? value : null;
3025 };
3126
32−const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string =>
33− readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId;
34−
3527 const readToolExposureModeFromRequest = (
3628 request: Request,
3729 fallbackMode: ExecuteToolExposureMode,
@@ -96,7 +88,6 @@ const hasWorkspaceAccess = async ( · 58 unmodified lines
9688 }
9789 };
9890
99−const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);
10091 const defaultToolExposureMode =
10192 parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ??
10293 defaultExecuteToolExposureMode;
@@ -104,45 +95,41 @@ const runtimeInternal = internal as any; · 1 unmodified line
10495
10596 export const mcpHandler = httpAction(async (ctx, request) => {
10697 const mcpAuthConfig = getMcpAuthConfig();
107− const requestWorkspaceId = readWorkspaceIdFromRequest(request);
108− const workspaceId = readRequestedWorkspaceId(request, fallbackWorkspaceId);
109− let accountId: string | null = null;
98+ const workspaceId = readWorkspaceIdFromRequest(request);
11099
111100 if (!mcpAuthConfig.enabled) {
112− if (mcpAuthConfig.required) {
113− return Response.json(
114− {
115− error: "MCP OAuth must be configured for cloud deployments",
116− },
117− { status: 503 },
118− );
119− }
120− } else {
121− const auth = await verifyMcpToken(request, mcpAuthConfig);
122− if (!auth) {
123− return unauthorizedMcpResponse(request, "No valid bearer token provided.");
124− }
101+ return Response.json(
102+ {
103+ error: "MCP OAuth must be configured",
104+ },
105+ { status: 503 },
106+ );
107+ }
125108
126− accountId = auth.subject;
109+ const auth = await verifyMcpToken(request, mcpAuthConfig);
110+ if (!auth) {
111+ return unauthorizedMcpResponse(request, "No valid bearer token provided.");
112+ }
127113
128− if (!requestWorkspaceId) {
129− return Response.json(
130− {
131− error: "workspaceId query parameter is required when MCP OAuth is enabled",
132− },
133− { status: 400 },
134− );
135− }
114+ if (!workspaceId) {
115+ return Response.json(
116+ {
117+ error: "workspaceId query parameter is required",
118+ },
119+ { status: 400 },
120+ );
121+ }
136122
137− const authorized = await hasWorkspaceAccess(ctx, requestWorkspaceId, auth.subject);
138− if (!authorized) {
139− return Response.json(
140− {
141− error: "Workspace authorization failed",
142− },
143− { status: 403 },
144− );
145− }
123+ const accountId = auth.subject;
124+
125+ const authorized = await hasWorkspaceAccess(ctx, workspaceId, accountId);
126+ if (!authorized) {
127+ return Response.json(
128+ {
129+ error: "Workspace authorization failed",
130+ },
131+ { status: 403 },
132+ );
146133 }
147134
148135 const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId, {
apps/convex/convex/runtimeCallbacks.ts+10−5
@@ -88,7 +88,7 @@ const decodeToolCallRequest = (request: Request): Effect.Effect<RuntimeToolCallR · 87 unmodified lines
8888 const resolveWorkspaceIdForToolCall = (
8989 ctx: ActionCtx,
9090 input: RuntimeToolCallRequest,
91−): Effect.Effect<{ workspaceId: string; accountId: string | null }, RuntimeToolCallBadRequestError> =>
91+): Effect.Effect<{ workspaceId: string; accountId: string }, RuntimeToolCallBadRequestError> =>
9292 Effect.gen(function* () {
9393 const runContext = yield* Effect.tryPromise({
9494 try: () =>
@@ -103,12 +103,17 @@ const resolveWorkspaceIdForToolCall = ( · 8 unmodified lines
103103 });
104104
105105 if (runContext && runContext.workspaceId.trim().length > 0) {
106+ const accountId = runContext.accountId.trim();
107+ if (accountId.length === 0) {
108+ return yield* new RuntimeToolCallBadRequestError({
109+ message: "Runtime callback run is missing account identity",
110+ details: `runId=${input.runId}`,
111+ });
112+ }
113+
106114 return {
107115 workspaceId: runContext.workspaceId.trim(),
108− accountId:
109− typeof runContext.accountId === "string" && runContext.accountId.trim().length > 0
110− ? runContext.accountId.trim()
111− : null,
116+ accountId,
112117 };
113118 }
114119
apps/convex/convex/source_tool_registry.ts+10−16
@@ -268,21 +268,15 @@ const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<st · 267 unmodified lines
268268 const resolveSourceCredentialHeadersForInvocation = async (
269269 ctx: ActionCtx,
270270 source: Source,
271− accountId: string | null,
271+ accountId: string,
272272 ): Promise<Record<string, string>> => {
273273 try {
274− const accountInput = accountId
275− ? {
276− accountId,
277− }
278− : {};
279−
280274 const resolved = (await ctx.runAction(
281275 runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders,
282276 {
283277 workspaceId: source.workspaceId,
284278 sourceId: source.id,
285− ...accountInput,
279+ accountId,
286280 },
287281 )) as { headers?: unknown };
288282
@@ -306,7 +300,7 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 17 unmodified lines
306300 const resolveSourceHeadersForInvocation = async (
307301 ctx: ActionCtx,
308302 source: Source,
309− accountId: string | null,
303+ accountId: string,
310304 ): Promise<Record<string, string>> => {
311305 const config = parseSourceConfig(source);
312306
@@ -1047,7 +1041,7 @@ const invokeToolWithRegistry = ( · 734 unmodified lines
10471041 source: Source,
10481042 artifactTool: ArtifactToolRow,
10491043 args: Record<string, unknown>,
1050− accountId: string | null,
1044+ accountId: string,
10511045 ): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> =>
10521046 Effect.gen(function* () {
10531047 const openApiProvider = makeOpenApiToolProvider();
@@ -1583,21 +1577,21 @@ const describeSource = (row: { · 529 unmodified lines
15831577 }): string => `${row.sourceKind.toUpperCase()} source: ${row.sourceName}`;
15841578
15851579 export type ConvexSourceToolRegistryOptions = {
1580+ accountId: string;
15861581 requireToolApprovals?: boolean;
15871582 approvalRetryAfterMs?: number;
1588− accountId?: string | null;
15891583 };
15901584
15911585 export const createConvexSourceToolRegistry = (
15921586 ctx: ActionCtx,
15931587 workspaceId: string,
1594− options: ConvexSourceToolRegistryOptions = {},
1588+ options: ConvexSourceToolRegistryOptions,
15951589 ): ToolRegistry => {
15961590 const requireApprovals = options.requireToolApprovals ?? requireToolApprovalsByDefault;
1597− const accountId =
1598− typeof options.accountId === "string" && options.accountId.trim().length > 0
1599− ? options.accountId.trim()
1600− : null;
1591+ const accountId = options.accountId.trim();
1592+ if (accountId.length === 0) {
1593+ throw new Error("Tool registry accountId is required");
1594+ }
16011595 const approvalRetryAfterMs =
16021596 typeof options.approvalRetryAfterMs === "number" &&
16031597 Number.isFinite(options.approvalRetryAfterMs) &&
apps/convex/convex/task_runs.ts+9−4
@@ -14,7 +14,7 @@ export const startTaskRun = internalMutation({ · 13 unmodified lines
1414 args: {
1515 workspaceId: v.string(),
1616 runId: v.string(),
17− accountId: v.optional(v.union(v.string(), v.null())),
17+ accountId: v.string(),
1818 sessionId: v.optional(v.string()),
1919 runtimeId: v.optional(v.string()),
2020 codeHash: v.optional(v.string()),
@@ -29,13 +29,18 @@ export const startTaskRun = internalMutation({ · 8 unmodified lines
2929 if (existing.workspaceId !== args.workspaceId) {
3030 throw new Error(`Task run workspace mismatch: ${args.runId}`);
3131 }
32+
33+ if (existing.accountId !== args.accountId) {
34+ throw new Error(`Task run account mismatch: ${args.runId}`);
35+ }
36+
3237 return;
3338 }
3439
3540 const taskRun = {
3641 id: args.runId,
3742 workspaceId: args.workspaceId,
38− accountId: args.accountId ?? null,
43+ accountId: args.accountId,
3944 sessionId: args.sessionId ?? "session_runtime",
4045 runtimeId: args.runtimeId ?? "runtime",
4146 codeHash: args.codeHash ?? "runtime",
@@ -83,7 +88,7 @@ export const getTaskRunContext = internalQuery({ · 41 unmodified lines
8388 args: {
8489 runId: v.string(),
8590 },
86− handler: async (ctx, args): Promise<{ workspaceId: string; accountId: string | null } | null> => {
91+ handler: async (ctx, args): Promise<{ workspaceId: string; accountId: string } | null> => {
8792 const existing = await ctx.db
8893 .query("taskRuns")
8994 .withIndex("by_domainId", (q) => q.eq("id", args.runId))
@@ -95,7 +100,7 @@ export const getTaskRunContext = internalQuery({ · 5 unmodified lines
95100
96101 return {
97102 workspaceId: existing.workspaceId,
98− accountId: typeof existing.accountId === "string" ? existing.accountId : null,
103+ accountId: existing.accountId,
99104 };
100105 },
101106 });
apps/web/app/api/control-plane/[...path]/route.ts+21−1
@@ -57,9 +57,29 @@ const buildUpstreamHeaders = ( · 56 unmodified lines
5757 }
5858 }
5959
60+ headers.set("accept-encoding", "identity");
61+
6062 return headers;
6163 };
6264
65+const responseHeadersToStrip = [
66+ "content-encoding",
67+ "content-length",
68+ "transfer-encoding",
69+ "connection",
70+ "keep-alive",
71+] as const;
72+
73+const sanitizeUpstreamResponseHeaders = (headers: Headers): Headers => {
74+ const sanitized = new Headers(headers);
75+
76+ for (const name of responseHeadersToStrip) {
77+ sanitized.delete(name);
78+ }
79+
80+ return sanitized;
81+};
82+
6383 const resolveUpstreamUrl = (
6484 request: NextRequest,
6585 pathSegments: ReadonlyArray<string>,
@@ -118,7 +138,7 @@ const handle = async (request: NextRequest, context: RouteContext): Promise<Resp · 52 unmodified lines
118138 return new Response(upstreamResponse.body, {
119139 status: upstreamResponse.status,
120140 statusText: upstreamResponse.statusText,
121− headers: upstreamResponse.headers,
141+ headers: sanitizeUpstreamResponseHeaders(upstreamResponse.headers),
122142 });
123143 };
124144
apps/web/next.config.ts+6−10
@@ -9,19 +9,15 @@ const trim = (value: string | undefined): string | undefined => { · 8 unmodified lines
99 return candidate && candidate.length > 0 ? candidate : undefined;
1010 };
1111
12−const publicControlPlaneBaseUrl =
13− trim(process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL)
14− ?? trim(process.env.CONTROL_PLANE_SERVER_BASE_URL)
15− ?? trim(process.env.CONTROL_PLANE_UPSTREAM_URL)
16− ?? trim(process.env.NEXT_PUBLIC_CONVEX_URL)
17− ?? trim(process.env.CONVEX_URL)
18− ?? "";
12+const publicControlPlaneBaseUrl = trim(process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL);
1913
2014 const nextConfig: NextConfig = {
2115 outputFileTracingRoot: resolve(appDir, "../.."),
22− env: {
23− NEXT_PUBLIC_CONTROL_PLANE_BASE_URL: publicControlPlaneBaseUrl,
24− },
16+ env: publicControlPlaneBaseUrl
17+ ? {
18+ NEXT_PUBLIC_CONTROL_PLANE_BASE_URL: publicControlPlaneBaseUrl,
19+ }
20+ : {},
2521 };
2622
2723 export default nextConfig;
packages/schema/src/models/task-run.ts+1−1
@@ -7,7 +7,7 @@ import { AccountIdSchema, TaskRunIdSchema, WorkspaceIdSchema } from "../ids"; · 6 unmodified lines
77 export const TaskRunSchema = Schema.Struct({
88 id: TaskRunIdSchema,
99 workspaceId: WorkspaceIdSchema,
10− accountId: Schema.NullOr(AccountIdSchema),
10+ accountId: AccountIdSchema,
1111 sessionId: Schema.String,
1212 runtimeId: Schema.String,
1313 codeHash: Schema.String,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t