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
26import type * as credential_crypto from "../credential_crypto.js";26import type * as credential_crypto from "../credential_crypto.js";
27import type * as executor from "../executor.js";27import type * as executor from "../executor.js";
28import type * as http from "../http.js";28import type * as http from "../http.js";
29import type * as http_security from "../http_security.js";
29import type * as mcp from "../mcp.js";30import type * as mcp from "../mcp.js";
30import type * as mcp_auth from "../mcp_auth.js";31import type * as mcp_auth from "../mcp_auth.js";
31import type * as run_executor from "../run_executor.js";32import type * as run_executor from "../run_executor.js";
@@ -61,6 +62,7 @@ declare const fullApi: ApiFromModules<{ · 29 unmodified lines
61 credential_crypto: typeof credential_crypto;62 credential_crypto: typeof credential_crypto;
62 executor: typeof executor;63 executor: typeof executor;
63 http: typeof http;64 http: typeof http;
65 http_security: typeof http_security;
64 mcp: typeof mcp;66 mcp: typeof mcp;
65 mcp_auth: typeof mcp_auth;67 mcp_auth: typeof mcp_auth;
66 run_executor: typeof run_executor;68 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
21 type: "customJwt",21 type: "customJwt",
22 issuer: `https://api.workos.com/user_management/${clientId}`,22 issuer: `https://api.workos.com/user_management/${clientId}`,
23 algorithm: "RS256",23 algorithm: "RS256",
24 applicationID: clientId,
25 jwks: `https://api.workos.com/sso/jwks/${clientId}`,24 jwks: `https://api.workos.com/sso/jwks/${clientId}`,
26 },25 },
27 ]26 ]
apps/convex/convex/control_plane/actor.ts+33−3
@@ -294,7 +294,7 @@ export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => · 293 unmodified lines
294 294
295 const principal = yield* resolveAuthenticatedPrincipal(ctx);295 const principal = yield* resolveAuthenticatedPrincipal(ctx);
296 296
297 const organizationMemberships = yield* runQueryEffect(297 let organizationMemberships = yield* runQueryEffect(
298 "organizationMembership.list",298 "organizationMembership.list",
299 () =>299 () =>
300 ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, {300 ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, {
@@ -302,17 +302,47 @@ export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => · 1 unmodified line
302 }),302 }),
303 );303 );
304 304
305 const organizationIds = organizationMemberships.map(305 let organizationIds = organizationMemberships.map(
306 (membership) => membership.organizationId,306 (membership) => membership.organizationId,
307 );307 );
308 308
309 const workspaces = yield* runQueryEffect("workspace.list", () =>309 let workspaces = yield* runQueryEffect("workspace.list", () =>
310 ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, {310 ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, {
311 accountId: principal.accountId,311 accountId: principal.accountId,
312 organizationIds,312 organizationIds,
313 }),313 }),
314 );314 );
315 315
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
316 const workspaceMemberships = workspaces.flatMap((workspace) =>346 const workspaceMemberships = workspaces.flatMap((workspace) =>
317 deriveWorkspaceMembershipsForPrincipal({347 deriveWorkspaceMembershipsForPrincipal({
318 principalAccountId: principal.accountId,348 principalAccountId: principal.accountId,
apps/convex/convex/executor.test.ts+1−0
@@ -1410,6 +1410,7 @@ describe("Convex executor and control-plane", () => { · 1409 unmodified lines
1410 t.mutation(runtimeInternal.task_runs.startTaskRun, {1410 t.mutation(runtimeInternal.task_runs.startTaskRun, {
1411 workspaceId: "ws_1",1411 workspaceId: "ws_1",
1412 runId: "run_approval_1",1412 runId: "run_approval_1",
1413 accountId: "acct_1",
1413 }),1414 }),
1414 );1415 );
1415 1416
apps/convex/convex/mcp.ts+30−43
@@ -19,19 +19,11 @@ import { · 18 unmodified lines
19} from "./mcp_auth";19} from "./mcp_auth";
20import { createConvexSourceToolRegistry } from "./source_tool_registry";20import { createConvexSourceToolRegistry } from "./source_tool_registry";
21 21
22const readConfiguredWorkspaceId = (value: string | undefined): string => {
23 const normalized = value?.trim();
24 return normalized && normalized.length > 0 ? normalized : "ws_local";
25};
26
27const readWorkspaceIdFromRequest = (request: Request): string | null => {22const readWorkspaceIdFromRequest = (request: Request): string | null => {
28 const value = new URL(request.url).searchParams.get("workspaceId")?.trim();23 const value = new URL(request.url).searchParams.get("workspaceId")?.trim();
29 return value && value.length > 0 ? value : null;24 return value && value.length > 0 ? value : null;
30};25};
31 26
32const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string =>
33 readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId;
34
35const readToolExposureModeFromRequest = (27const readToolExposureModeFromRequest = (
36 request: Request,28 request: Request,
37 fallbackMode: ExecuteToolExposureMode,29 fallbackMode: ExecuteToolExposureMode,
@@ -96,7 +88,6 @@ const hasWorkspaceAccess = async ( · 58 unmodified lines
96 }88 }
97};89};
98 90
99const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID);
100const defaultToolExposureMode =91const defaultToolExposureMode =
101 parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ??92 parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ??
102 defaultExecuteToolExposureMode;93 defaultExecuteToolExposureMode;
@@ -104,45 +95,41 @@ const runtimeInternal = internal as any; · 1 unmodified line
104 95
105export const mcpHandler = httpAction(async (ctx, request) => {96export const mcpHandler = httpAction(async (ctx, request) => {
106 const mcpAuthConfig = getMcpAuthConfig();97 const mcpAuthConfig = getMcpAuthConfig();
107 const requestWorkspaceId = readWorkspaceIdFromRequest(request);98 const workspaceId = readWorkspaceIdFromRequest(request);
108 const workspaceId = readRequestedWorkspaceId(request, fallbackWorkspaceId);
109 let accountId: string | null = null;
110 99
111 if (!mcpAuthConfig.enabled) {100 if (!mcpAuthConfig.enabled) {
112 if (mcpAuthConfig.required) {101 return Response.json(
113 return Response.json(102 {
114 {103 error: "MCP OAuth must be configured",
115 error: "MCP OAuth must be configured for cloud deployments",104 },
116 },105 { status: 503 },
117 { status: 503 },106 );
118 );107 }
119 }
120 } else {
121 const auth = await verifyMcpToken(request, mcpAuthConfig);
122 if (!auth) {
123 return unauthorizedMcpResponse(request, "No valid bearer token provided.");
124 }
125 108
126 accountId = auth.subject;109 const auth = await verifyMcpToken(request, mcpAuthConfig);
110 if (!auth) {
111 return unauthorizedMcpResponse(request, "No valid bearer token provided.");
112 }
127 113
128 if (!requestWorkspaceId) {114 if (!workspaceId) {
129 return Response.json(115 return Response.json(
130 {116 {
131 error: "workspaceId query parameter is required when MCP OAuth is enabled",117 error: "workspaceId query parameter is required",
132 },118 },
133 { status: 400 },119 { status: 400 },
134 );120 );
135 }121 }
136 122
137 const authorized = await hasWorkspaceAccess(ctx, requestWorkspaceId, auth.subject);123 const accountId = auth.subject;
138 if (!authorized) {124
139 return Response.json(125 const authorized = await hasWorkspaceAccess(ctx, workspaceId, accountId);
140 {126 if (!authorized) {
141 error: "Workspace authorization failed",127 return Response.json(
142 },128 {
143 { status: 403 },129 error: "Workspace authorization failed",
144 );130 },
145 }131 { status: 403 },
132 );
146 }133 }
147 134
148 const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId, {135 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
88const resolveWorkspaceIdForToolCall = (88const resolveWorkspaceIdForToolCall = (
89 ctx: ActionCtx,89 ctx: ActionCtx,
90 input: RuntimeToolCallRequest,90 input: RuntimeToolCallRequest,
91): Effect.Effect<{ workspaceId: string; accountId: string | null }, RuntimeToolCallBadRequestError> =>91): Effect.Effect<{ workspaceId: string; accountId: string }, RuntimeToolCallBadRequestError> =>
92 Effect.gen(function* () {92 Effect.gen(function* () {
93 const runContext = yield* Effect.tryPromise({93 const runContext = yield* Effect.tryPromise({
94 try: () =>94 try: () =>
@@ -103,12 +103,17 @@ const resolveWorkspaceIdForToolCall = ( · 8 unmodified lines
103 });103 });
104 104
105 if (runContext && runContext.workspaceId.trim().length > 0) {105 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
106 return {114 return {
107 workspaceId: runContext.workspaceId.trim(),115 workspaceId: runContext.workspaceId.trim(),
108 accountId:116 accountId,
109 typeof runContext.accountId === "string" && runContext.accountId.trim().length > 0
110 ? runContext.accountId.trim()
111 : null,
112 };117 };
113 }118 }
114 119
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
268const resolveSourceCredentialHeadersForInvocation = async (268const resolveSourceCredentialHeadersForInvocation = async (
269 ctx: ActionCtx,269 ctx: ActionCtx,
270 source: Source,270 source: Source,
271 accountId: string | null,271 accountId: string,
272): Promise<Record<string, string>> => {272): Promise<Record<string, string>> => {
273 try {273 try {
274 const accountInput = accountId
275 ? {
276 accountId,
277 }
278 : {};
279
280 const resolved = (await ctx.runAction(274 const resolved = (await ctx.runAction(
281 runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders,275 runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders,
282 {276 {
283 workspaceId: source.workspaceId,277 workspaceId: source.workspaceId,
284 sourceId: source.id,278 sourceId: source.id,
285 ...accountInput,279 accountId,
286 },280 },
287 )) as { headers?: unknown };281 )) as { headers?: unknown };
288 282
@@ -306,7 +300,7 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 17 unmodified lines
306const resolveSourceHeadersForInvocation = async (300const resolveSourceHeadersForInvocation = async (
307 ctx: ActionCtx,301 ctx: ActionCtx,
308 source: Source,302 source: Source,
309 accountId: string | null,303 accountId: string,
310): Promise<Record<string, string>> => {304): Promise<Record<string, string>> => {
311 const config = parseSourceConfig(source);305 const config = parseSourceConfig(source);
312 306
@@ -1047,7 +1041,7 @@ const invokeToolWithRegistry = ( · 734 unmodified lines
1047 source: Source,1041 source: Source,
1048 artifactTool: ArtifactToolRow,1042 artifactTool: ArtifactToolRow,
1049 args: Record<string, unknown>,1043 args: Record<string, unknown>,
1050 accountId: string | null,1044 accountId: string,
1051): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> =>1045): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> =>
1052 Effect.gen(function* () {1046 Effect.gen(function* () {
1053 const openApiProvider = makeOpenApiToolProvider();1047 const openApiProvider = makeOpenApiToolProvider();
@@ -1583,21 +1577,21 @@ const describeSource = (row: { · 529 unmodified lines
1583}): string => `${row.sourceKind.toUpperCase()} source: ${row.sourceName}`;1577}): string => `${row.sourceKind.toUpperCase()} source: ${row.sourceName}`;
1584 1578
1585export type ConvexSourceToolRegistryOptions = {1579export type ConvexSourceToolRegistryOptions = {
1580 accountId: string;
1586 requireToolApprovals?: boolean;1581 requireToolApprovals?: boolean;
1587 approvalRetryAfterMs?: number;1582 approvalRetryAfterMs?: number;
1588 accountId?: string | null;
1589};1583};
1590 1584
1591export const createConvexSourceToolRegistry = (1585export const createConvexSourceToolRegistry = (
1592 ctx: ActionCtx,1586 ctx: ActionCtx,
1593 workspaceId: string,1587 workspaceId: string,
1594 options: ConvexSourceToolRegistryOptions = {},1588 options: ConvexSourceToolRegistryOptions,
1595): ToolRegistry => {1589): ToolRegistry => {
1596 const requireApprovals = options.requireToolApprovals ?? requireToolApprovalsByDefault;1590 const requireApprovals = options.requireToolApprovals ?? requireToolApprovalsByDefault;
1597 const accountId =1591 const accountId = options.accountId.trim();
1598 typeof options.accountId === "string" && options.accountId.trim().length > 01592 if (accountId.length === 0) {
1599 ? options.accountId.trim()1593 throw new Error("Tool registry accountId is required");
1600 : null;1594 }
1601 const approvalRetryAfterMs =1595 const approvalRetryAfterMs =
1602 typeof options.approvalRetryAfterMs === "number" &&1596 typeof options.approvalRetryAfterMs === "number" &&
1603 Number.isFinite(options.approvalRetryAfterMs) &&1597 Number.isFinite(options.approvalRetryAfterMs) &&
apps/convex/convex/task_runs.ts+9−4
@@ -14,7 +14,7 @@ export const startTaskRun = internalMutation({ · 13 unmodified lines
14 args: {14 args: {
15 workspaceId: v.string(),15 workspaceId: v.string(),
16 runId: v.string(),16 runId: v.string(),
17 accountId: v.optional(v.union(v.string(), v.null())),17 accountId: v.string(),
18 sessionId: v.optional(v.string()),18 sessionId: v.optional(v.string()),
19 runtimeId: v.optional(v.string()),19 runtimeId: v.optional(v.string()),
20 codeHash: v.optional(v.string()),20 codeHash: v.optional(v.string()),
@@ -29,13 +29,18 @@ export const startTaskRun = internalMutation({ · 8 unmodified lines
29 if (existing.workspaceId !== args.workspaceId) {29 if (existing.workspaceId !== args.workspaceId) {
30 throw new Error(`Task run workspace mismatch: ${args.runId}`);30 throw new Error(`Task run workspace mismatch: ${args.runId}`);
31 }31 }
32
33 if (existing.accountId !== args.accountId) {
34 throw new Error(`Task run account mismatch: ${args.runId}`);
35 }
36
32 return;37 return;
33 }38 }
34 39
35 const taskRun = {40 const taskRun = {
36 id: args.runId,41 id: args.runId,
37 workspaceId: args.workspaceId,42 workspaceId: args.workspaceId,
38 accountId: args.accountId ?? null,43 accountId: args.accountId,
39 sessionId: args.sessionId ?? "session_runtime",44 sessionId: args.sessionId ?? "session_runtime",
40 runtimeId: args.runtimeId ?? "runtime",45 runtimeId: args.runtimeId ?? "runtime",
41 codeHash: args.codeHash ?? "runtime",46 codeHash: args.codeHash ?? "runtime",
@@ -83,7 +88,7 @@ export const getTaskRunContext = internalQuery({ · 41 unmodified lines
83 args: {88 args: {
84 runId: v.string(),89 runId: v.string(),
85 },90 },
86 handler: async (ctx, args): Promise<{ workspaceId: string; accountId: string | null } | null> => {91 handler: async (ctx, args): Promise<{ workspaceId: string; accountId: string } | null> => {
87 const existing = await ctx.db92 const existing = await ctx.db
88 .query("taskRuns")93 .query("taskRuns")
89 .withIndex("by_domainId", (q) => q.eq("id", args.runId))94 .withIndex("by_domainId", (q) => q.eq("id", args.runId))
@@ -95,7 +100,7 @@ export const getTaskRunContext = internalQuery({ · 5 unmodified lines
95 100
96 return {101 return {
97 workspaceId: existing.workspaceId,102 workspaceId: existing.workspaceId,
98 accountId: typeof existing.accountId === "string" ? existing.accountId : null,103 accountId: existing.accountId,
99 };104 };
100 },105 },
101});106});
apps/web/app/api/control-plane/[...path]/route.ts+21−1
@@ -57,9 +57,29 @@ const buildUpstreamHeaders = ( · 56 unmodified lines
57 }57 }
58 }58 }
59 59
60 headers.set("accept-encoding", "identity");
61
60 return headers;62 return headers;
61};63};
62 64
65const responseHeadersToStrip = [
66 "content-encoding",
67 "content-length",
68 "transfer-encoding",
69 "connection",
70 "keep-alive",
71] as const;
72
73const 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
63const resolveUpstreamUrl = (83const resolveUpstreamUrl = (
64 request: NextRequest,84 request: NextRequest,
65 pathSegments: ReadonlyArray<string>,85 pathSegments: ReadonlyArray<string>,
@@ -118,7 +138,7 @@ const handle = async (request: NextRequest, context: RouteContext): Promise<Resp · 52 unmodified lines
118 return new Response(upstreamResponse.body, {138 return new Response(upstreamResponse.body, {
119 status: upstreamResponse.status,139 status: upstreamResponse.status,
120 statusText: upstreamResponse.statusText,140 statusText: upstreamResponse.statusText,
121 headers: upstreamResponse.headers,141 headers: sanitizeUpstreamResponseHeaders(upstreamResponse.headers),
122 });142 });
123};143};
124 144
apps/web/next.config.ts+6−10
@@ -9,19 +9,15 @@ const trim = (value: string | undefined): string | undefined => { · 8 unmodified lines
9 return candidate && candidate.length > 0 ? candidate : undefined;9 return candidate && candidate.length > 0 ? candidate : undefined;
10};10};
11 11
12const publicControlPlaneBaseUrl =12const publicControlPlaneBaseUrl = trim(process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL);
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 ?? "";
19 13
20const nextConfig: NextConfig = {14const nextConfig: NextConfig = {
21 outputFileTracingRoot: resolve(appDir, "../.."),15 outputFileTracingRoot: resolve(appDir, "../.."),
22 env: {16 env: publicControlPlaneBaseUrl
23 NEXT_PUBLIC_CONTROL_PLANE_BASE_URL: publicControlPlaneBaseUrl,17 ? {
24 },18 NEXT_PUBLIC_CONTROL_PLANE_BASE_URL: publicControlPlaneBaseUrl,
19 }
20 : {},
25};21};
26 22
27export default nextConfig;23export default nextConfig;
packages/schema/src/models/task-run.ts+1−1
@@ -7,7 +7,7 @@ import { AccountIdSchema, TaskRunIdSchema, WorkspaceIdSchema } from "../ids"; · 6 unmodified lines
7export const TaskRunSchema = Schema.Struct({7export const TaskRunSchema = Schema.Struct({
8 id: TaskRunIdSchema,8 id: TaskRunIdSchema,
9 workspaceId: WorkspaceIdSchema,9 workspaceId: WorkspaceIdSchema,
10 accountId: Schema.NullOr(AccountIdSchema),10 accountId: AccountIdSchema,
11 sessionId: Schema.String,11 sessionId: Schema.String,
12 runtimeId: Schema.String,12 runtimeId: Schema.String,
13 codeHash: Schema.String,13 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