From 8fa68283ba5b6eaacc7b8ff0cf35777995503884 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 8 Oct 2026 06:43:47 -0500 Subject: [PATCH] fix(connections): distinguish consent from missing Google tools Preserve shared actions only when filtered by the caller's narrower grant; disable failed-service and removed tools even when another grant has their scopes. Cover cross-grant outages, removed actions, and Docs read/write consent. Co-Authored-By: Paperclip --- doc/connections/GOOGLE-WORKSPACE.md | 5 ++- .../src/__tests__/tool-access-service.test.ts | 43 ++++++++++++++++--- server/src/services/tool-access.ts | 8 +++- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index 3869ea4e8ca..d22b61209a1 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -25,8 +25,9 @@ connection's health message identifies unavailable services and recommends a catalog refresh. Failed services' cached actions are disabled until rediscovered; an outage affecting every requested service still fails the refresh. Loss of the shared OAuth grant never degrades into a successful partial discovery. Because -the catalog is shared, a caller's narrower consent never removes actions another -active grant supports; each caller's listing and dispatch still use only their +the catalog is shared, actions skipped because of narrower consent are preserved +when another active grant supports them. Failed services and tools removed from a +successful discovery are still disabled. Each caller's listing and dispatch use only their selected grant, without combining permissions from different identities. Existing individual product connections and their direct setup routes remain diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index d9be213630f..aa7ed1cab4a 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -8102,12 +8102,12 @@ describeEmbeddedPostgres("tool access service", () => { }); describe("combined Workspace service isolation and refresh consent", () => { - async function connectWorkspace(mode: "managed" | "customer") { + async function connectWorkspace(mode: "managed" | "customer", permissions = ["documents.readonly", "gmail.readonly"]) { const company = await createCompany(db); const userId = `workspace-refresh-${randomUUID()}`; await grantBoardUser(db, company.id, userId, [], "owner"); const actor = { actorType: "user" as const, actorId: userId }; - const initialScopes = ["documents.readonly", "gmail.readonly"].map((scope) => `https://www.googleapis.com/auth/${scope}`); + const initialScopes = permissions.map((scope) => `https://www.googleapis.com/auth/${scope}`); let authorizationScopes = initialScopes; let refreshScopes: string[] | undefined = initialScopes; const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all"); @@ -8119,6 +8119,7 @@ describeEmbeddedPostgres("tool access service", () => { const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); const calls: string[] = []; const unavailable = new Set(); + const omitted = new Set(); let unauthorizedNextCall = false; const tokenRefreshes: string[] = []; vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { @@ -8137,10 +8138,11 @@ describeEmbeddedPostgres("tool access service", () => { if (unauthorizedNextCall) { unauthorizedNextCall = false; return new Response("expired", { status: 401 }); } return Response.json({ jsonrpc: "2.0", id: body.id, result: { content: [{ type: "text", text: "ok" }] } }); } - return mcpHttpResponse({ jsonrpc: "2.0", id: body.id, result: { tools: [{ - name: host === "docsmcp.googleapis.com" ? "read_doc" : "get_message", annotations: { readOnlyHint: true }, + const names = host === "docsmcp.googleapis.com" ? ["read_doc", "update_doc"] : ["get_message"]; + return mcpHttpResponse({ jsonrpc: "2.0", id: body.id, result: { tools: omitted.has(host) ? [] : names.map((name) => ({ + name, annotations: { readOnlyHint: name !== "update_doc" }, inputSchema: { type: "object", properties: {} }, - }] } }); + })) } }); }); const connected = await service.connectGalleryApp(company.id, { galleryKey: "google-workspace", grantKind: "organization", @@ -8162,13 +8164,13 @@ describeEmbeddedPostgres("tool access service", () => { const gateway = createToolGatewayService(db, { toolActionSigningSecret: "test-secret", oauthGrantRefresher: service.refreshOAuthGrantCredentials }); const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); - return { company, actor, agent, service, connected, gateway, session, calls, unavailable, connector, tokenRefreshes, + return { company, actor, agent, service, connected, gateway, session, calls, unavailable, omitted, connector, tokenRefreshes, setAuthorizationScopes: (scopes: string[]) => { authorizationScopes = scopes; }, expireNextCall: (scopes: string[] | undefined) => { refreshScopes = scopes; unauthorizedNextCall = true; } }; } it("preserves organization actions when a personal OAuth callback grants fewer permissions", async () => { - const fixture = await connectWorkspace("customer"); + const fixture = await connectWorkspace("customer", ["documents", "gmail.readonly"]); // This supported policy selects personal consent when present and the // organization identity for callers without a personal grant. await db.update(toolConnections).set({ credentialPolicy: "per_user_with_fallback" }) @@ -8187,6 +8189,12 @@ describeEmbeddedPostgres("tool access service", () => { eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message"), )); expect(gmailEntry.status).toBe("active"); + const [docsWrite] = await db.select().from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "docs__update_doc"), + )); + // Docs was probed, but its write action was filtered by personal consent, + // not removed by Google. The organization still retains that action. + expect(docsWrite.status).toBe("active"); const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!; expect(gmail).toBeTruthy(); await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); @@ -8203,6 +8211,27 @@ describeEmbeddedPostgres("tool access service", () => { expect(removed.status).toBe("disabled"); }); + it.each(["outage", "removed"] as const)("does not preserve a probed Gmail service's missing tools across grants (%s)", async (failure) => { + const fixture = await connectWorkspace("customer"); + const userId = `workspace-both-${randomUUID()}`; + await grantBoardUser(db, fixture.company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; + const started = await fixture.service.startOAuth(fixture.company.id, fixture.connected.connectionId, { redirectUri, actor, subjectUserId: userId }); + await fixture.service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "personal-both", redirectUri, actor }); + (failure === "outage" ? fixture.unavailable : fixture.omitted).add("gmailmcp.googleapis.com"); + const refreshed = await fixture.service.refreshCatalog(fixture.connected.connectionId, actor); + expect(refreshed.connection.healthStatus).toBe("ok"); + const tools = await fixture.gateway.listToolsForSession(fixture.session.token); + expect(tools.some((tool) => tool.upstreamToolName === "gmail__get_message")).toBe(false); + const doc = tools.find((tool) => tool.upstreamToolName === "docs__read_doc")!; + expect(doc).toBeTruthy(); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + expect(fixture.calls).toEqual(["read_doc"]); + const [gmail] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message"))); + expect(gmail.status).toBe("disabled"); + }); + it("keeps healthy service tools visible during a partial outage and restores recovered services", async () => { const fixture = await connectWorkspace("managed"); fixture.unavailable.add("gmailmcp.googleapis.com"); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 9a5fadb619b..0471e4bb6d1 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -7805,16 +7805,22 @@ export function toolAccessService( // narrower personal consent must not remove actions another active grant // still supports. Never combine scopes from different grants into authority. let otherWorkspaceGrants: string[][] = []; + let selectedWorkspaceScopes: string[] = []; if (connection.config.sourceTemplateKey === "google-workspace") { const selected = await vaultGrantForConnection(connection, actor); + selectedWorkspaceScopes = googleWorkspaceGrantedScopes(selected); const grants = await db.select().from(connectionGrants).where(and( eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"), )); otherWorkspaceGrants = grants.filter((grant) => grant.id !== selected?.id).map(googleWorkspaceGrantedScopes); } + // Preserve only consent-filtered actions, not failed-service catalogs or + // tools a successful, sufficiently authorized discovery actually removed. const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== "disabled" - && !otherWorkspaceGrants.some((scopes) => isGoogleWorkspaceToolGranted(entry.toolName, scopes))).map((entry) => entry.id); + && !(otherWorkspaceGrants.some((scopes) => isGoogleWorkspaceToolGranted(entry.toolName, scopes)) + && !isGoogleWorkspaceToolGranted(entry.toolName, selectedWorkspaceScopes) + && !unavailableServices.includes(googleWorkspaceToolTarget(entry.toolName)?.service ?? ""))).map((entry) => entry.id); if (removedIds.length) await db.update(toolCatalogEntries) .set({ status: "disabled", quarantineReason: "mcp_tool_removed", updatedAt: refreshedAt }) .where(and(eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, removedIds)));