mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
1609c305d0
commit
8fa68283ba
3 files changed
+46
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<string>();
|
||||
const omitted = new Set<string>();
|
||||
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");
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user