mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
fix(connections): isolate Google service discovery failures
Exercise managed and customer token refresh through gateway retries, reject invalid consent evidence, and retain healthy services during partial outages. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
948a29f0fd
commit
7aa8bb147b
4 files changed
+170
-9
No files matched your search
@@ -20,6 +20,12 @@ permissions; discovery skips services without grants and excludes unsupported
|
||||
actions. The gateway checks actual grant scopes again before each call, including
|
||||
after refresh. Requested scopes alone never authorize an action.
|
||||
|
||||
Discovery isolates service outages. Healthy services remain usable while the
|
||||
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.
|
||||
|
||||
Existing individual product connections and their direct setup routes remain
|
||||
supported. They are not automatically merged, upgraded or reauthorized. The Sheets
|
||||
robot-account method remains separate because it uses explicitly shared files,
|
||||
|
||||
@@ -8101,6 +8101,127 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
expect(JSON.stringify(completed)).not.toContain("workspace-access-token");
|
||||
});
|
||||
|
||||
describe("combined Workspace service isolation and refresh consent", () => {
|
||||
async function connectWorkspace(mode: "managed" | "customer") {
|
||||
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}`);
|
||||
let refreshScopes: string[] | undefined = initialScopes;
|
||||
const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all");
|
||||
const originalClaim = connector.claim;
|
||||
connector.claim = vi.fn(async (input) => ({ ...await originalClaim(input), scopes: initialScopes,
|
||||
accessTokenExpiresAt: new Date(Date.now() + 86_400_000).toISOString() }));
|
||||
connector.refresh = vi.fn(async () => ({ ...await connector.claim({ subject: userId, companyId: company.id,
|
||||
profile: "workspace.all", claimId: "fixture", redemptionId: "fixture" }), scopes: refreshScopes ?? [] }));
|
||||
const service = createTestToolAccessService(db, { paperclipCloudConnector: connector });
|
||||
const calls: string[] = [];
|
||||
const unavailable = new Set<string>();
|
||||
let unauthorizedNextCall = false;
|
||||
const tokenRefreshes: string[] = [];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => {
|
||||
const href = String(url);
|
||||
if (href === "https://oauth2.googleapis.com/token") {
|
||||
const refreshing = new URLSearchParams(String(init?.body)).get("grant_type") === "refresh_token";
|
||||
if (refreshing) tokenRefreshes.push(href);
|
||||
return Response.json({ access_token: "workspace-refresh-access", refresh_token: "workspace-refresh-token",
|
||||
token_type: "Bearer", expires_in: 86_400, scope: (refreshing ? refreshScopes : initialScopes)?.join(" ") });
|
||||
}
|
||||
const host = new URL(href).hostname;
|
||||
if (unavailable.has(host)) return new Response("unavailable", { status: 503 });
|
||||
const body = JSON.parse(String(init?.body));
|
||||
if (body.method === "tools/call") {
|
||||
calls.push(body.params.name);
|
||||
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 },
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}] } });
|
||||
});
|
||||
const connected = await service.connectGalleryApp(company.id, {
|
||||
galleryKey: "google-workspace", grantKind: "organization",
|
||||
connectionMethodKey: mode === "managed" ? "paperclip-workspace" : "customer-workspace-oauth",
|
||||
...(mode === "customer" ? { oauthClient: { clientId: "workspace-client", clientSecret: "workspace-client-secret" } } : {}),
|
||||
}, actor);
|
||||
const redirectUri = mode === "managed" ? "https://paperclip.example/api/tools/oauth/cloud-connector/callback" : "https://paperclip.example/api/tools/oauth/callback";
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor });
|
||||
const state = new URL(started.authorizationUrl).searchParams.get("state")!;
|
||||
const completed = mode === "managed"
|
||||
? await service.completePaperclipCloudConnectorCallback({ state, claimId: "fixture", actor })
|
||||
: await service.completeOAuthCallback({ state, code: "fixture", redirectUri, actor });
|
||||
const agent = await createAgent(db, company.id);
|
||||
await service.finishGalleryAppConnection(company.id, connected.connectionId, {
|
||||
enabledCatalogEntryIds: completed.catalog.map((entry) => entry.id), askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] },
|
||||
}, actor);
|
||||
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
|
||||
invocationSource: "on_demand", status: "running", contextSnapshot: {} }).returning();
|
||||
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, service, connected, gateway, session, calls, unavailable, connector, tokenRefreshes,
|
||||
expireNextCall: (scopes: string[] | undefined) => { refreshScopes = scopes; unauthorizedNextCall = true; } };
|
||||
}
|
||||
|
||||
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");
|
||||
const refreshed = await fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor);
|
||||
expect(refreshed.connection).toMatchObject({ healthStatus: "ok", healthMessage: expect.stringContaining("gmail") });
|
||||
expect(refreshed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]);
|
||||
const tools = await fixture.gateway.listToolsForSession(fixture.session.token);
|
||||
const doc = tools.find((tool) => tool.upstreamToolName === "docs__read_doc")!;
|
||||
expect(doc).toBeTruthy();
|
||||
expect(tools.some((tool) => tool.upstreamToolName === "gmail__get_message")).toBe(false);
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
||||
const health = await fixture.service.checkHealth(fixture.connected.connectionId, fixture.actor);
|
||||
expect(health.connection).toMatchObject({ healthStatus: "ok", healthMessage: expect.stringContaining("gmail") });
|
||||
const events = await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.connectionId, fixture.connected.connectionId));
|
||||
expect(events).toContainEqual(expect.objectContaining({ reasonCode: "google_workspace_service_unavailable", details: { service: "gmail" } }));
|
||||
fixture.unavailable.clear();
|
||||
const recovered = await fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor);
|
||||
expect(recovered.catalog.map((entry) => entry.toolName).sort()).toEqual(["docs__read_doc", "gmail__get_message"]);
|
||||
expect(recovered.connection.healthMessage).not.toContain("unavailable");
|
||||
fixture.unavailable.add("gmailmcp.googleapis.com");
|
||||
fixture.unavailable.add("docsmcp.googleapis.com");
|
||||
await expect(fixture.service.refreshCatalog(fixture.connected.connectionId, fixture.actor)).rejects.toMatchObject({ details: { code: "google_workspace_services_unavailable" } });
|
||||
});
|
||||
|
||||
it.each(["managed", "customer"] as const)("blocks the auth retry and updates listing after %s refresh narrows consent", async (mode) => {
|
||||
const fixture = await connectWorkspace(mode);
|
||||
const tool = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((item) => item.upstreamToolName === "docs__read_doc")!;
|
||||
expect(tool).toBeTruthy();
|
||||
const scopes = ["https://www.googleapis.com/auth/gmail.readonly"];
|
||||
fixture.expireNextCall(scopes);
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_insufficient_scope" });
|
||||
expect(fixture.calls).toEqual(["read_doc"]); // No second tools/call after the 401 refresh removed Docs permission.
|
||||
const [grant] = await db.select().from(connectionGrants).where(and(eq(connectionGrants.connectionId, fixture.connected.connectionId), eq(connectionGrants.status, "active")));
|
||||
expect(grant.providerTenant?.oauth?.scopes).toEqual(scopes);
|
||||
if (mode === "managed") expect(fixture.connector.refresh).toHaveBeenCalled();
|
||||
else expect(fixture.tokenRefreshes).toHaveLength(1);
|
||||
const tools = await fixture.gateway.listToolsForSession(fixture.session.token);
|
||||
expect(tools.some((item) => item.upstreamToolName === "docs__read_doc")).toBe(false);
|
||||
const gmail = tools.find((item) => item.upstreamToolName === "gmail__get_message")!;
|
||||
expect(gmail).toBeTruthy();
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).resolves.toMatchObject({ status: "completed" });
|
||||
});
|
||||
|
||||
it.each((["managed", "customer"] as const).flatMap((mode) => [undefined, [], ["https://www.googleapis.com/auth/gmail.send"]].map((scopes) => ({ mode, scopes }))))(
|
||||
"fails closed on invalid refreshed permissions ($mode, $scopes)", async ({ mode, scopes }) => {
|
||||
const fixture = await connectWorkspace(mode);
|
||||
const tool = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((item) => item.upstreamToolName === "docs__read_doc")!;
|
||||
fixture.expireNextCall(scopes);
|
||||
await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: tool.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "oauth_reauthorization_required" });
|
||||
expect(fixture.calls).toEqual(["read_doc"]);
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, fixture.connected.connectionId));
|
||||
expect(grant.status).toBe("needs_reauthorization");
|
||||
expect((await fixture.gateway.listToolsForSession(fixture.session.token)).some((item) => item.connectionId === fixture.connected.connectionId)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("routes a managed Drive callback into the personal vault, filtered catalog, and provider-specific activity", async () => {
|
||||
const company = await createCompany(db);
|
||||
const userId = `drive-member-${randomUUID()}`;
|
||||
|
||||
@@ -64,11 +64,17 @@ describe("Paperclip Cloud connector", () => {
|
||||
const connector = createPaperclipCloudConnector({ config: keys.config,
|
||||
request: vi.fn(async () => Response.json({ sealed })) as typeof fetch });
|
||||
await expect(connector.claim({ subject, companyId, profile: "workspace.all", claimId: "test-claim", redemptionId: "test-state" })).resolves.toEqual(credentials);
|
||||
sealed = seal(credentials, keys.sealPublicKey, "access", keys.config, "workspace.all");
|
||||
await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).resolves.toEqual(credentials);
|
||||
for (const invalid of [[], [...scopes, "https://www.googleapis.com/auth/gmail.send"]]) {
|
||||
credentials.scopes = invalid;
|
||||
sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "workspace.all");
|
||||
await expect(connector.claim({ subject, companyId, profile: "workspace.all", claimId: "test-claim", redemptionId: "test-state" })).rejects.toMatchObject({ code: "REAUTHORIZATION_REQUIRED" });
|
||||
sealed = seal(credentials, keys.sealPublicKey, "access", keys.config, "workspace.all");
|
||||
await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).rejects.toMatchObject({ code: "REAUTHORIZATION_REQUIRED" });
|
||||
}
|
||||
sealed = seal({ ...credentials, scopes: undefined }, keys.sealPublicKey, "access", keys.config, "workspace.all");
|
||||
await expect(connector.refresh({ subject, companyId, profile: "workspace.all", refreshToken: "test-refresh" })).rejects.toBeInstanceOf(PaperclipCloudConnectorError);
|
||||
});
|
||||
async function rejection(response: Response) {
|
||||
const connector = createPaperclipCloudConnector({
|
||||
|
||||
@@ -2921,7 +2921,7 @@ function sanitizeHttpFailure(error: unknown): {
|
||||
if (code === "grant_credential_invalid" || code === "oauth_insufficient_scope") {
|
||||
return { status: code === "grant_credential_invalid" ? "missing_secret" : "degraded", message: error.message, code };
|
||||
}
|
||||
if (code === "slack_mcp_access_disabled") {
|
||||
if (code === "slack_mcp_access_disabled" || code === "google_workspace_services_unavailable") {
|
||||
return { status: "error", message: error.message, code };
|
||||
}
|
||||
if (code === "user_authorization_required") {
|
||||
@@ -7122,6 +7122,7 @@ export function toolAccessService(
|
||||
credentialHeaders?: Record<string, string>,
|
||||
actor?: ActorInfo,
|
||||
workspaceEndpoint?: string,
|
||||
unavailableServices: string[] = [],
|
||||
): Promise<McpToolDescriptor[]> {
|
||||
assertSupportedConnection(connection);
|
||||
let headers = credentialHeaders ?? {
|
||||
@@ -7134,16 +7135,31 @@ export function toolAccessService(
|
||||
const services = (Object.keys(GOOGLE_WORKSPACE_SERVICES) as GoogleWorkspaceService[])
|
||||
.filter((service) => googleWorkspaceServiceGranted(service, scopes));
|
||||
// One credential authority, no child connections or implied service grants.
|
||||
const catalogs = await Promise.all(services.map(async (service) => {
|
||||
const catalogs = await Promise.allSettled(services.map(async (service) => {
|
||||
const profile = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[GOOGLE_WORKSPACE_SERVICES[service]];
|
||||
const tools = await remoteTools(connection, headers, actor, profile.serverUrl);
|
||||
return tools.map((tool) => ({ ...tool, name: googleWorkspaceToolName(service, tool.name) }))
|
||||
.filter((tool) => isGoogleWorkspaceToolGranted(tool.name, scopes));
|
||||
}));
|
||||
for (const [index, catalog] of catalogs.entries()) {
|
||||
if (catalog.status === "fulfilled") continue;
|
||||
const service = services[index]!;
|
||||
unavailableServices.push(service);
|
||||
await audit({ companyId: connection.companyId, connectionId: connection.id,
|
||||
action: "tool_connection.catalog_refresh", outcome: "failure", actor,
|
||||
reasonCode: "google_workspace_service_unavailable", details: { service } });
|
||||
}
|
||||
const currentGrant = await vaultGrantForConnection(connection, actor);
|
||||
if (!currentGrant) throw unprocessable("OAuth authorization must be reconnected", { code: "oauth_reauthorization_required" });
|
||||
if (catalogs.length > 0 && catalogs.every((catalog) => catalog.status === "rejected")) {
|
||||
throw new HttpError(502, `Google services unavailable: ${unavailableServices.join(", ")}. Retry refreshing actions.`,
|
||||
{ code: "google_workspace_services_unavailable" });
|
||||
}
|
||||
// A service's authentication retry may have refreshed into a narrower
|
||||
// grant. Never publish actions using the pre-refresh permission snapshot.
|
||||
const currentScopes = googleWorkspaceGrantedScopes(await vaultGrantForConnection(connection, actor));
|
||||
return catalogs.flat().filter((tool) => isGoogleWorkspaceToolGranted(tool.name, currentScopes));
|
||||
const currentScopes = googleWorkspaceGrantedScopes(currentGrant);
|
||||
return catalogs.flatMap((catalog) => catalog.status === "fulfilled" ? catalog.value : [])
|
||||
.filter((tool) => isGoogleWorkspaceToolGranted(tool.name, currentScopes));
|
||||
}
|
||||
const endpoint = workspaceEndpoint ?? await resolvedRemoteEndpoint(connection, actor);
|
||||
// Pinned to the address the guard approved: `config.url` is operator-supplied,
|
||||
@@ -7474,6 +7490,7 @@ export function toolAccessService(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
credentialHeaders?: Record<string, string>,
|
||||
actor?: ActorInfo,
|
||||
unavailableServices: string[] = [],
|
||||
): Promise<McpToolDescriptor[]> {
|
||||
assertSupportedConnection(connection);
|
||||
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions");
|
||||
@@ -7487,7 +7504,7 @@ export function toolAccessService(
|
||||
return [];
|
||||
}
|
||||
if (connection.transport === "mcp_remote")
|
||||
return remoteTools(connection, credentialHeaders, actor);
|
||||
return remoteTools(connection, credentialHeaders, actor, undefined, unavailableServices);
|
||||
if (connection.transport !== "local_stdio") {
|
||||
throw unsupportedToolConnectionTransport();
|
||||
}
|
||||
@@ -7591,6 +7608,7 @@ export function toolAccessService(
|
||||
): Promise<ToolConnectionHealthCheckResult> {
|
||||
const connection = await getConnectionRow(connectionId);
|
||||
if (connection.connectionPurpose === "ai") return { connection: toConnection(connection), runtimeSlot: null };
|
||||
const unavailableServices: string[] = [];
|
||||
try {
|
||||
assertSupportedConnection(connection);
|
||||
const config = asRecord(connection.config);
|
||||
@@ -7652,7 +7670,7 @@ export function toolAccessService(
|
||||
: canProbeWithoutAuthorization
|
||||
? {}
|
||||
: undefined;
|
||||
await remoteTools(connection, credentialHeaders, actor);
|
||||
await remoteTools(connection, credentialHeaders, actor, undefined, unavailableServices);
|
||||
} else if (connection.transport === "local_stdio") {
|
||||
await resolveCredentialHeaders(connection);
|
||||
await stdioTemplateId(connection.companyId, connection.config);
|
||||
@@ -7663,7 +7681,9 @@ export function toolAccessService(
|
||||
const updated = await updateConnectionHealth(
|
||||
connection,
|
||||
"ok",
|
||||
config.sourceTemplateKey === "github" &&
|
||||
unavailableServices.length > 0
|
||||
? `Connected. Google services unavailable: ${unavailableServices.join(", ")}. Other services remain available; retry refreshing actions.`
|
||||
: config.sourceTemplateKey === "github" &&
|
||||
oauth.connectorProfile === "github.code"
|
||||
? "GitHub account, installation, and repository access are available."
|
||||
: isBrowserUseConnection(connection)
|
||||
@@ -7734,12 +7754,14 @@ export function toolAccessService(
|
||||
const connection = await getConnectionRow(connectionId);
|
||||
if (connection.connectionPurpose === "ai") throw unprocessable("AI connections do not have a tool catalog");
|
||||
const refreshedAt = now();
|
||||
const unavailableServices: string[] = [];
|
||||
let descriptors: McpToolDescriptor[];
|
||||
try {
|
||||
descriptors = await discoverTools(
|
||||
connection,
|
||||
refreshOptions.credentialHeaders,
|
||||
actor,
|
||||
unavailableServices,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -7776,7 +7798,8 @@ export function toolAccessService(
|
||||
const existingByName = new Map(
|
||||
existingRows.map((entry) => [entry.toolName, entry]),
|
||||
);
|
||||
if (isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
if (connection.config.sourceTemplateKey === "google-workspace"
|
||||
|| isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) {
|
||||
const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name));
|
||||
const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== "disabled").map((entry) => entry.id);
|
||||
if (removedIds.length) await db.update(toolCatalogEntries)
|
||||
@@ -7938,7 +7961,9 @@ export function toolAccessService(
|
||||
config: normalizedConfig,
|
||||
transportConfig: normalizedTransportConfig,
|
||||
healthStatus: "ok",
|
||||
healthMessage: isAgentMailConnection(connection)
|
||||
healthMessage: unavailableServices.length > 0
|
||||
? `Actions refreshed. Google services unavailable: ${unavailableServices.join(", ")}. Other services remain available; retry refreshing actions.`
|
||||
: isAgentMailConnection(connection)
|
||||
? "AgentMail API key is connected."
|
||||
: "Tool catalog refreshed.",
|
||||
healthCheckedAt: refreshedAt,
|
||||
@@ -11463,6 +11488,9 @@ export function toolAccessService(
|
||||
profile: profile.id,
|
||||
refreshToken: refreshSecret.value,
|
||||
});
|
||||
if (profile.id === "workspace.all" && !isGoogleWorkspaceScopeGrant(credentials.scopes)) {
|
||||
throw new PaperclipCloudConnectorError("Google did not return verifiable permissions", "REAUTHORIZATION_REQUIRED");
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof PaperclipCloudConnectorError &&
|
||||
|
||||
Reference in new issue
Block a user