diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index ff76addd514..0fe3bb93299 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -1,7 +1,7 @@ # Google Workspace connections -Paperclip presents Google Workspace as nine independent Apps entries, not as -one combined Google connection: +New Google Workspace setup uses one `google-workspace` connection and the +`workspace.all` OAuth profile for these nine services: 1. Gmail 2. Google Drive @@ -13,19 +13,65 @@ one combined Google connection: 8. Google People 9. Google Workspace Search -Each entry creates its own connection, consent grant, capability catalog, -policy, audit trail, and reconnect/revoke lifecycle. Connecting Drive does not -create a Docs or Sheets connection, and connecting Gmail does not enable -Workspace Search. +The combined connection has one credential grant, capability catalog, policy +profile, audit trail and reconnect lifecycle. It requests the existing 21-scope +union, not additional permissions. Google's consent screen lets users decline +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. + +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, +not the user's Google OAuth identity. Google's hosted Workspace MCP servers are Developer Preview services. The app -cards remain independent even when several services use the same customer-owned -Google OAuth client or the same Paperclip Cloud broker deployment. +endpoints remain separate. Paperclip namespaces actions by service and routes them +to a closed endpoint registry using the combined grant. It does not create child +connections or send provider tokens through Paperclip ID. + +The catalog reuses the existing reviewed Google mark (`google-people.svg`), +registered under its own `google-workspace` brand identity. No new artwork is +downloaded or substituted for a product logo. + +## Combined profile deployment and verification + +Deploy the companion Cloud broker registry before enabling `workspace.all` in +`CLOUD_HARNESS_CONNECTOR_GOOGLE_ENABLED_PROFILES`. It uses the existing WORKSPACE +client pair; that client's Cloud project must have every applicable MCP API and +preview enrollment. Do not remove legacy enabled profiles while their grants are +in use. This code change does not enable the broker profile or deploy any stack. + +The signed request and envelope authentication bind the exact 21-scope requested +profile; the encrypted credential payload preserves Google's actual nonempty +subset. Missing scope evidence and scopes outside that reviewed union fail closed +on authorization and refresh. Individual legacy profiles retain exact matching. +For the new `google/workspace.all` profile only, HKDF info is SHA-256(AAD) to +remain below Node's 1024-byte limit. AES-GCM still authenticates the complete AAD; +legacy envelope key derivation is unchanged. + +Managed OAuth: browser → Cloud `/v1/connector/sessions` → Google +`https://accounts.google.com/o/oauth2/v2/auth` → Cloud +`/v1/connector/oauth/google/callback` → instance +`/api/tools/oauth/cloud-connector/callback`. Cloud exchanges codes and refreshes at +`https://oauth2.googleapis.com/token`; provider tool calls go directly from the +instance to the fixed endpoints below. Customer OAuth uses the instance's existing +`/api/tools/oauth/callback` instead. Register that exact HTTPS or loopback callback +on the customer-owned client. + +Removal is local for managed grants: Google's project-wide revocation can break +other connections. Before rollout, verify full and partial consent with an enrolled +test account, allowed reads/writes, reconnect, refresh and removal. Deterministic +tests are not real-provider proof. Combining profiles simplifies onboarding, not +Google's per-scope demonstration, justification or security-assessment obligations. + +Sources: [granular consent](https://developers.google.com/identity/protocols/oauth2/resources/granular-permissions), +[Workspace MCP setup](https://developers.google.com/workspace/guides/configure-mcp-servers). ## Temporary Connections page visibility hold While Google OAuth verification is pending, the Connections landing page -(`ui/src/pages/apps/Browse.tsx`) hides all nine Google Workspace entries, +(`ui/src/pages/apps/Browse.tsx`) hides Google Workspace and the nine legacy entries, including their saved accounts. This is a display-only filter. App definitions, direct setup and management routes, OAuth profiles, saved credentials, and runtime tools remain unchanged. This is not an access-control restriction. @@ -60,7 +106,7 @@ registered internal testers during preview. Other companies must enroll their own Workspace testers and Cloud project and use a customer-owned OAuth app until Google makes Workspace MCP generally available. -## App matrix +## Service endpoints and legacy capability choices | App card | MCP endpoint | Capability choices | | --- | --- | --- | @@ -74,7 +120,9 @@ Google makes Workspace MCP generally available. | Google People | `https://people.googleapis.com/mcp/v1` | Read contacts | | Google Workspace Search | `https://workspacemcp.googleapis.com/mcp/v1` | Search Workspace | -The setup flow asks for the capability first. When the managed method is +Legacy product setup exposes capability choices under Change. The combined +Workspace setup has one capability and defers permission selection to Google. +When the managed method is available, it uses Paperclip by default. A small **Use your own Google OAuth app** link reveals the custom client fields; **Use Paperclip instead** returns to the managed method. The available authentication methods are: @@ -89,8 +137,8 @@ managed method. The available authentication methods are: - **Use the Paperclip robot account** remains an additional Google Sheets-only option for explicitly shared spreadsheets. -Before Google consent, the setup flow asks whether the credential is for just -the connecting user or for any human in the company. A personal choice stores +Before Google consent, the setup flow shows the credential's human and agent +access and lets the user change it. A personal choice stores the tokens only on that user's grant. A company choice stores them on the default organization grant, while still recording which signed-in Google principal completed consent so refresh and reconnect stay bound to that @@ -104,7 +152,7 @@ callback, refresh, and revoke, while the broker enforces current profile access. Switching capability or authentication methods preserves the selected credential owner when the new method supports that owner. -## Broker profiles +## Legacy product-specific broker profiles The Paperclip-managed method signs every broker request with one explicit profile. The broker binds that profile into sessions, one-time claims, sealed diff --git a/doc/connections/tool-method-permission-reviews.json b/doc/connections/tool-method-permission-reviews.json index f92216c57bf..581eb0d3349 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -2,6 +2,80 @@ "reviewedAt": "2026-09-30", "evidenceLevel": "Official documentation and public MCP metadata; no account-bound read/write proof. Runtime discovery never expands this allowlist.", "methods": [ + { + "app": "google-workspace", + "method": "paperclip-workspace", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/presentations.readonly", + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly", + "https://www.googleapis.com/auth/chat.messages.create", + "https://www.googleapis.com/auth/directory.readonly", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/calendar.readonly" + ], + "capability": "write", + "supportedActions": "One grant for the existing reviewed Google actions. Per-action scope checks support partial consent; Gmail send and Chat membership/read-state remain unsupported.", + "evidence": [ + "https://developers.google.com/workspace/guides/configure-mcp-servers", + "https://developers.google.com/identity/protocols/oauth2/resources/granular-permissions" + ], + "reviewedAt": "2026-10-08", + "liveProof": "not-run" + }, + { + "app": "google-workspace", + "method": "customer-workspace-oauth", + "auth": "oauth", + "policy": "explicit", + "requestedScopes": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/presentations.readonly", + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly", + "https://www.googleapis.com/auth/chat.messages.create", + "https://www.googleapis.com/auth/directory.readonly", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/calendar.readonly" + ], + "capability": "write", + "supportedActions": "One grant for the existing reviewed Google actions. Per-action scope checks support partial consent; Gmail send and Chat membership/read-state remain unsupported.", + "evidence": [ + "https://developers.google.com/workspace/guides/configure-mcp-servers", + "https://developers.google.com/identity/protocols/oauth2/resources/granular-permissions" + ], + "reviewedAt": "2026-10-08", + "liveProof": "not-run" + }, { "app": "airtable", "method": "mcp-oauth", diff --git a/doc/plans/2026-10-08-unified-google-workspace-connector.md b/doc/plans/2026-10-08-unified-google-workspace-connector.md new file mode 100644 index 00000000000..dd951ea77c0 --- /dev/null +++ b/doc/plans/2026-10-08-unified-google-workspace-connector.md @@ -0,0 +1,39 @@ +# Unified Google Workspace connector + +## Finish line + +One connection, OAuth consent, credential grant and action profile for the nine +existing Google services. Google controls granular consent; only actually granted +actions are discoverable and executable. No new scopes, Gmail sends, Chat +membership or unread-state access. Existing product connections remain compatible. + +## Scope and rollout + +- App branch merged with `origin/master` at `6dc32b57f`. +- App delivery branch: `codex/unified-google-workspace`. +- Companion Cloud broker branch: `codex/google-workspace-connector`. +- Reuse normal setup, vault, policies, refresh and audit paths. +- Keep the temporary Connections-page hold. No production deployment, Console + changes, reviewer-stack upgrade or unpinning as part of this implementation. +- Sign-in tokens are never reused as resource tokens; id.paperclip.ing never + stores resource tokens; no connections hub on the ID service. + +## Acceptance + +- Full and partial consent, exact requested scope union, one saved connection. +- Namespaced tools route to fixed service endpoints; unknown tools fail closed. +- Declined/revoked scopes block cached agent and board calls. +- Reconnect preserves narrower local action policies and credential ownership. +- Existing scopes, individual profiles and the Sheets robot method survive. +- Deterministic checks plus local browser setup inspection. Real Google OAuth + remains a separate release gate until an enrolled test client is available. + +## Current state + +Implementation complete in the app and broker. Focused app setup, vault, gateway, +all-service routing and consent tests pass. Broker integration tests (32), its +full suite (2,871 passed; 77 skipped), app build, workspace typecheck, brand asset +validation and token gates pass. +The production setup components were checked in a background browser using +simulated Storybook data: one profile, personal access and OAuth method switching. +Full regression checks and PR review are in progress. No live Google proof yet. diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index 5a6482dbec6..56e1947082e 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -71,23 +71,24 @@ import a69 from "./app-definitions/honcho.json" with { type: "json" }; import a70 from "./app-definitions/neon.json" with { type: "json" }; import a71 from "./app-definitions/superagent.json" with { type: "json" }; import a72 from "./app-definitions/telem.json" with { type: "json" }; -import a73 from "./app-definitions/gmail.json" with { type: "json" }; -import a74 from "./app-definitions/google-drive.json" with { type: "json" }; -import a75 from "./app-definitions/google-docs.json" with { type: "json" }; -import a76 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a77 from "./app-definitions/google-slides.json" with { type: "json" }; -import a78 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a79 from "./app-definitions/google-chat.json" with { type: "json" }; -import a80 from "./app-definitions/google-people.json" with { type: "json" }; -import a81 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a82 from "./app-definitions/openai.json" with { type: "json" }; -import a83 from "./app-definitions/openrouter.json" with { type: "json" }; -import a84 from "./app-definitions/xai.json" with { type: "json" }; -import a85 from "./app-definitions/google.json" with { type: "json" }; -import a86 from "./app-definitions/bedrock.json" with { type: "json" }; -import a87 from "./app-definitions/responses-api.json" with { type: "json" }; -import a88 from "./app-definitions/messages-api.json" with { type: "json" }; -import a89 from "./app-definitions/chat-completions-api.json" with { type: "json" }; -import a90 from "./app-definitions/local.json" with { type: "json" }; +import a73 from "./app-definitions/google-workspace.json" with { type: "json" }; +import a74 from "./app-definitions/gmail.json" with { type: "json" }; +import a75 from "./app-definitions/google-drive.json" with { type: "json" }; +import a76 from "./app-definitions/google-docs.json" with { type: "json" }; +import a77 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a78 from "./app-definitions/google-slides.json" with { type: "json" }; +import a79 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a80 from "./app-definitions/google-chat.json" with { type: "json" }; +import a81 from "./app-definitions/google-people.json" with { type: "json" }; +import a82 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a83 from "./app-definitions/openai.json" with { type: "json" }; +import a84 from "./app-definitions/openrouter.json" with { type: "json" }; +import a85 from "./app-definitions/xai.json" with { type: "json" }; +import a86 from "./app-definitions/google.json" with { type: "json" }; +import a87 from "./app-definitions/bedrock.json" with { type: "json" }; +import a88 from "./app-definitions/responses-api.json" with { type: "json" }; +import a89 from "./app-definitions/messages-api.json" with { type: "json" }; +import a90 from "./app-definitions/chat-completions-api.json" with { type: "json" }; +import a91 from "./app-definitions/local.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70,a71,a72,a73,a74,a75,a76,a77,a78,a79,a80,a81,a82,a83,a84,a85,a86,a87,a88,a89,a90,a91] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 3167197d008..7b6bbfec52c 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -845,7 +845,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(68); + expect(APP_STORE_DEFINITIONS).toHaveLength(69); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); @@ -917,7 +917,7 @@ describe("AppDefinition catalog", () => { }); it("keeps all Google Workspace profiles aligned with their app, endpoint, scopes, ownership, risk, and write policy", () => { expect(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS).toEqual( - GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS.map((entry) => entry.profile), + [...GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS.map((entry) => entry.profile), "workspace.all"], ); expect(Object.keys(GOOGLE_WORKSPACE_CONNECTOR_PROFILES)).toEqual([ ...GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS, diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 4f0a1e6c894..083471bf607 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -31,6 +31,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([ "google-chat", "google-people", "google-workspace-search", + "google-workspace", "github", "github-code-review-bot", "discord", diff --git a/packages/shared/src/app-definitions/google-workspace.json b/packages/shared/src/app-definitions/google-workspace.json new file mode 100644 index 00000000000..8bb5dc2fd16 --- /dev/null +++ b/packages/shared/src/app-definitions/google-workspace.json @@ -0,0 +1,133 @@ +{ + "schemaVersion": 1, + "slug": "google-workspace", + "name": "Google Workspace", + "description": "Connect Gmail, Drive, Docs, Sheets, Slides, Calendar, Chat, contacts, and Workspace Search with one Google account.", + "categories": [ + "productivity", + "communication", + "content" + ], + "featured": true, + "branding": { + "logoUrl": "/brands/apps/google-people.svg" + }, + "urlPatterns": [], + "docsUrl": "https://developers.google.com/workspace/guides/configure-mcp-servers", + "setupPrerequisite": { + "title": "Google Developer Preview access required", + "description": "Google must register the Workspace email and the Cloud project that owns the OAuth client before these services can be used.", + "actionLabel": "Check preview enrollment", + "actionUrl": "https://developers.google.com/workspace/preview" + }, + "redirectConstraints": "https-or-loopback-http", + "methods": [ + { + "key": "paperclip-workspace", + "label": "Connect with Paperclip", + "transport": "mcp_remote", + "auth": "oauth", + "oauthStrategy": "paperclip_cloud_connector", + "connectorProfile": "workspace.all", + "capabilityProfile": { + "key": "workspace", + "label": "Google Workspace", + "description": "Choose which permissions to grant on Google's consent screen." + }, + "grantKinds": [ + "user", + "organization" + ], + "ownershipModes": [ + "platform_shared" + ], + "whenToUse": "Use one Paperclip-managed sign-in for all Google services.", + "defaults": { + "serverUrl": "https://workspacemcp.googleapis.com/mcp/v1", + "scopesHint": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/presentations.readonly", + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly", + "https://www.googleapis.com/auth/chat.messages.create", + "https://www.googleapis.com/auth/directory.readonly", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/calendar.readonly" + ] + }, + "guidanceMd": "Choose the permissions you want on Google's consent screen. Only actions covered by those permissions will be available. Gmail supports reading and creating drafts, not sending.", + "riskTier": "S4" + }, + { + "key": "customer-workspace-oauth", + "label": "Use your own Google OAuth app", + "transport": "mcp_remote", + "auth": "oauth", + "capabilityProfile": { + "key": "workspace", + "label": "Google Workspace", + "description": "Choose which permissions to grant on Google's consent screen." + }, + "grantKinds": [ + "user", + "organization" + ], + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use one customer-owned Google OAuth client for all Google services.", + "defaults": { + "serverUrl": "https://workspacemcp.googleapis.com/mcp/v1", + "authorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "tokenEndpoint": "https://oauth2.googleapis.com/token", + "metadataUrl": "https://accounts.google.com/.well-known/openid-configuration", + "scopesHint": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/presentations.readonly", + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events.freebusy", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages.readonly", + "https://www.googleapis.com/auth/chat.messages.create", + "https://www.googleapis.com/auth/directory.readonly", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/contacts.readonly", + "https://www.googleapis.com/auth/calendar.readonly" + ], + "oauthAuthorizationParams": { + "access_type": "offline", + "prompt": "consent" + } + }, + "guidanceMd": "Enable the Google Workspace MCP APIs and register Paperclip's callback URI. Choose which permissions to grant on Google's consent screen; declined permissions disable only the corresponding actions.", + "consoleLinks": { + "register": "https://console.cloud.google.com/auth/clients", + "docs": "https://developers.google.com/workspace/guides/configure-mcp-servers" + }, + "riskTier": "S4" + } + ] +} diff --git a/packages/shared/src/google-workspace-connectors.test.ts b/packages/shared/src/google-workspace-connectors.test.ts new file mode 100644 index 00000000000..151ae70849c --- /dev/null +++ b/packages/shared/src/google-workspace-connectors.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { getConnectableAppDefinition } from "./app-definitions.js"; +import { + GOOGLE_WORKSPACE_CONNECTOR_PROFILES as profiles, GOOGLE_WORKSPACE_SERVICES, + googleWorkspaceGrantedScopes, googleWorkspaceToolTarget, isGoogleWorkspaceToolGranted, + googleWorkspaceServiceGranted, isGoogleWorkspaceScopeGrant, +} from "./google-workspace-connectors.js"; + +const scope = (value: string) => `https://www.googleapis.com/auth/${value}`; + +describe("combined Google Workspace consent", () => { + it("requests the existing union with one profile and preserves legacy definitions", () => { + const expected = [...new Set(Object.entries(profiles).filter(([key]) => key !== "workspace.all").flatMap(([, value]) => value.scopes))]; + expect(profiles["workspace.all"].scopes).toEqual(expected); + expect(expected).toHaveLength(21); + const app = getConnectableAppDefinition("google-workspace")!; + expect(app.methods).toHaveLength(2); + expect(app.methods.filter((method) => method.connectorProfile)).toHaveLength(1); + for (const method of app.methods) expect(method.defaults?.scopesHint).toEqual(expected); + for (const profile of Object.values(GOOGLE_WORKSPACE_SERVICES)) expect(getConnectableAppDefinition(profiles[profile].appSlug)).not.toBeNull(); + }); + + it("accepts a nonempty subset but not missing or unrequested permissions", () => { + expect(isGoogleWorkspaceScopeGrant([scope("documents")])).toBe(true); + expect(isGoogleWorkspaceScopeGrant([])).toBe(false); + expect(isGoogleWorkspaceScopeGrant([scope("chat.memberships.readonly")])).toBe(false); + expect(isGoogleWorkspaceScopeGrant([scope("gmail.send")])).toBe(false); + }); + + it("declining Gmail leaves Drive available without enabling Gmail or Docs", () => { + const granted = [scope("drive.readonly")]; + expect(isGoogleWorkspaceToolGranted("drive__search_files", granted)).toBe(true); + expect(isGoogleWorkspaceToolGranted("drive__create_file", granted)).toBe(false); + expect(isGoogleWorkspaceToolGranted("gmail__get_message", granted)).toBe(false); + expect(isGoogleWorkspaceToolGranted("docs__read_doc", granted)).toBe(false); + expect(googleWorkspaceServiceGranted("gmail", granted)).toBe(false); + expect(googleWorkspaceServiceGranted("drive", granted)).toBe(true); + expect(isGoogleWorkspaceToolGranted("search__search_corpus", granted)).toBe(false); + }); + + it("allows read-only grants and write grants for the corresponding editors", () => { + for (const [service, permission, read, write] of [ + ["docs", "documents", "read_doc", "update_doc"], + ["sheets", "spreadsheets", "get_values", "update_values"], + ["slides", "presentations", "read_presentation", "update_presentation"], + ]) { + expect(isGoogleWorkspaceToolGranted(`${service}__${read}`, [scope(`${permission}.readonly`)])).toBe(true); + expect(isGoogleWorkspaceToolGranted(`${service}__${write}`, [scope(`${permission}.readonly`)])).toBe(false); + expect(isGoogleWorkspaceToolGranted(`${service}__${read}`, [scope(permission!)])).toBe(true); + expect(isGoogleWorkspaceToolGranted(`${service}__${write}`, [scope(permission!)])).toBe(true); + } + }); + + it("routes colliding tool names to closed endpoints and rejects unknown actions", () => { + expect(googleWorkspaceToolTarget("gmail__search_messages")?.serverUrl).toContain("gmailmcp.googleapis.com"); + expect(googleWorkspaceToolTarget("chat__search_messages")?.serverUrl).toContain("chatmcp.googleapis.com"); + for (const name of ["gmail__send_message", "chat__list_members", "docs__delete_doc", "evil__get_message", "__proto____get_message", "gmail__constructor"]) + expect(googleWorkspaceToolTarget(name)).toBeNull(); + expect(isGoogleWorkspaceToolGranted("gmail__send_message", profiles["workspace.all"].scopes)).toBe(false); + }); + + it("never substitutes requested scopes for actual consent", () => { + expect(googleWorkspaceGrantedScopes(null)).toEqual([]); + expect(googleWorkspaceGrantedScopes({ providerTenant: { oauth: { scopes: [scope("documents")], scopeSource: "requested_fallback" } } })).toEqual([]); + expect(googleWorkspaceGrantedScopes({ providerTenant: { oauth: { scopes: [scope("documents")], scopeSource: "provider" } } })).toEqual([scope("documents")]); + }); +}); diff --git a/packages/shared/src/google-workspace-connectors.ts b/packages/shared/src/google-workspace-connectors.ts index 5800309de16..f3debbf7627 100644 --- a/packages/shared/src/google-workspace-connectors.ts +++ b/packages/shared/src/google-workspace-connectors.ts @@ -2,13 +2,14 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS = [ "gmail.read", "gmail.draft", "drive.read", "drive.write", "docs.read", "docs.write", "sheets.read", "sheets.write", "slides.read", "slides.write", "calendar.read", "calendar.write", "chat.read", "chat.write", "people.read", "workspace-search.read", + "workspace.all", ] as const; export type GoogleWorkspaceConnectorProfileId = (typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS)[number]; const auth = (scope: string) => `https://www.googleapis.com/auth/${scope}`; -export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly, { appSlug: string; serverUrl: string; scopes: readonly string[]; @@ -32,6 +33,98 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly profile.scopes))]), +}; + +export const GOOGLE_WORKSPACE_SERVICES = { + gmail: "gmail.draft", drive: "drive.write", docs: "docs.write", sheets: "sheets.write", + slides: "slides.write", calendar: "calendar.write", chat: "chat.write", + people: "people.read", search: "workspace-search.read", +} as const; +export type GoogleWorkspaceService = keyof typeof GOOGLE_WORKSPACE_SERVICES; + +// Each inner group is a set of authorization alternatives; every group is required. +// Keep the reviewed tool allowlist closed, even when upstream adds tools. +const one = (...scopes: string[]) => [scopes.map(auth)]; +const toolScopes: Record> = { + gmail: Object.fromEntries([ + ...["get_message", "get_thread", "get_draft", "list_drafts", "list_labels", "search_threads", "list_threads", "search_messages"] + .map((name) => [name, one("gmail.readonly")]), + ["create_draft", one("gmail.compose")], + ]), + drive: Object.fromEntries([ + ...["download_file_content", "get_file_metadata", "get_file_permissions", "list_recent_files", "read_file_content", "search_files"] + .map((name) => [name, one("drive.readonly")]), + ...["copy_file", "create_file"].map((name) => [name, one("drive.file")]), + ]), + docs: { read_doc: one("documents.readonly", "documents"), update_doc: one("documents") }, + sheets: Object.fromEntries([ + ...["get_values", "get_spreadsheet"].map((name) => [name, one("spreadsheets.readonly", "spreadsheets")]), + ...["update_spreadsheet", "update_values", "update_formulas", "insert_dimension"].map((name) => [name, one("spreadsheets")]), + ]), + slides: { read_presentation: one("presentations.readonly", "presentations"), update_presentation: one("presentations") }, + calendar: Object.fromEntries([ + ["list_calendars", one("calendar.calendarlist.readonly", "calendar.readonly")], + ...["get_event", "list_events", "search_events"].map((name) => [name, one("calendar.events.readonly", "calendar.events", "calendar.readonly")]), + ["suggest_time", one("calendar.events.freebusy", "calendar.events", "calendar.readonly")], + ...["create_event", "update_event", "delete_event", "respond_to_event"].map((name) => [name, one("calendar.events")]), + ]), + chat: { + search_conversations: one("chat.spaces.readonly"), + list_messages: one("chat.messages.readonly"), search_messages: one("chat.messages.readonly"), + send_message: one("chat.messages.create"), + }, + people: { + search_directory_people: one("directory.readonly"), search_contacts: one("contacts.readonly"), + get_user_profile: one("userinfo.profile"), + }, + search: { search_corpus: SERVICE_PROFILES["workspace-search.read"].scopes.map((scope) => [scope]) }, +}; + +export function googleWorkspaceToolName(service: GoogleWorkspaceService, upstreamName: string): string { + return `${service}__${upstreamName}`; +} + +/** The endpoint is derived from a closed registry, never provider annotations or caller input. */ +export function googleWorkspaceToolTarget(name: string) { + const separator = name.indexOf("__"); + const service = name.slice(0, separator) as GoogleWorkspaceService; + if (separator < 1 || !Object.hasOwn(GOOGLE_WORKSPACE_SERVICES, service)) return null; + const upstreamName = name.slice(separator + 2); + const leaf = (upstreamName.split(/[.:/]/).pop() ?? upstreamName) + .replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/-/g, "_").toLowerCase(); + if (!Object.hasOwn(toolScopes[service], leaf)) return null; + const profile = SERVICE_PROFILES[GOOGLE_WORKSPACE_SERVICES[service]]; + return { service, upstreamName, leaf, serverUrl: profile.serverUrl, appSlug: profile.appSlug, + requiredScopes: toolScopes[service][leaf]!, write: profile.writeTools.some((tool) => tool === leaf) }; +} + +export function isGoogleWorkspaceToolGranted(name: string, scopes: readonly string[]): boolean { + const target = googleWorkspaceToolTarget(name); + return Boolean(target && target.requiredScopes.every((alternatives) => alternatives.some((scope) => scopes.includes(scope)))); +} + +export function isGoogleWorkspaceScopeGrant(scopes: readonly string[]): boolean { + const allowed = GOOGLE_WORKSPACE_CONNECTOR_PROFILES["workspace.all"].scopes; + return scopes.length > 0 && scopes.every((scope) => allowed.includes(scope)); +} + +export function googleWorkspaceServiceGranted(service: GoogleWorkspaceService, scopes: readonly string[]): boolean { + return Object.keys(toolScopes[service]).some((name) => isGoogleWorkspaceToolGranted(googleWorkspaceToolName(service, name), scopes)); +} + +/** Requested scopes and connection-wide defaults are never evidence of consent. */ +export function googleWorkspaceGrantedScopes(grant: { providerTenant?: unknown } | null): string[] { + const tenant = grant?.providerTenant as { oauth?: { scopes?: unknown; scopeSource?: unknown } } | undefined; + const oauth = tenant?.oauth; + if (oauth?.scopeSource === "requested_fallback" || !Array.isArray(oauth?.scopes)) return []; + return oauth.scopes.filter((scope): scope is string => typeof scope === "string"); +} + export function isGoogleWorkspaceConnectorProfileId(value: string): value is GoogleWorkspaceConnectorProfileId { return Object.prototype.hasOwnProperty.call(GOOGLE_WORKSPACE_CONNECTOR_PROFILES, value); } diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 42dbb080689..b6e7dc5e356 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1774,10 +1774,10 @@ for (const entry of researchManifest.entries) { }); } // Google Workspace definitions are reviewed, first-class app entries rather -// than rows synthesized from the generic connection corpus. Keep each product -// independent in the generated manifest while sharing only backend OAuth -// infrastructure. +// than rows synthesized from the generic connection corpus. Keep legacy product +// definitions for saved connections alongside the combined Workspace entry. const reviewedGoogleSlugs = [ + "google-workspace", "gmail", "google-drive", "google-docs", diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 27b8c6d8b4f..20dc810fabf 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -2464,6 +2464,43 @@ describeEmbeddedPostgres("tool access service", () => { expect(reconnected.connection.agentInstructions).toEqual(settings); }); + it.each(["https://www.googleapis.com/auth/documents.readonly", undefined, "", "https://www.googleapis.com/auth/gmail.send"])( + "requires explicit reviewed partial consent for customer Workspace OAuth (%s)", async (scope) => { + const company = await createCompany(db); + const userId = `workspace-customer-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const service = createTestToolAccessService(db); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user", + oauthClient: { clientId: "test-workspace-client", clientSecret: "test-workspace-secret" }, + }, actor); + const redirectUri = "https://paperclip.example.test/api/tools/oauth/callback"; + const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri, actor }); + const authorization = new URL(started.authorizationUrl); + expect(authorization.searchParams.get("scope")?.split(" ")).toEqual([...GOOGLE_WORKSPACE_CONNECTOR_PROFILES["workspace.all"].scopes]); + expect(authorization.searchParams.get("enable_granular_consent")).toBe("true"); + const provider = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + if (String(url) === "https://oauth2.googleapis.com/token") return Response.json({ + access_token: "fixture-workspace-access", refresh_token: "fixture-workspace-refresh", expires_in: 3600, token_type: "Bearer", scope, + }); + expect(String(url)).toBe("https://docsmcp.googleapis.com/mcp/v1"); + return mcpHttpResponse({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools: [ + { name: "read_doc", annotations: { readOnlyHint: true } }, { name: "update_doc" }, + ] } }); + }); + const completing = service.completeOAuthCallback({ state: authorization.searchParams.get("state")!, code: "fixture-code", redirectUri, actor }); + if (scope?.endsWith("documents.readonly")) { + const completed = await completing; + expect(completed.connection.status).toBe("active"); + expect(completed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]); + } else { + await expect(completing).rejects.toMatchObject({ details: { code: "oauth_scope_grant_invalid" } }); + expect(provider).toHaveBeenCalledTimes(1); + } + }, + ); + it.each(["read", "write"])("requests only reduced Chat scopes for customer-owned %s OAuth, including reconnect", async (capability) => { const company = await createCompany(db); const service = createTestToolAccessService(db); @@ -5184,7 +5221,7 @@ describeEmbeddedPostgres("tool access service", () => { "telem", ]), ); - expect(res.body.apps).toHaveLength(68); + expect(res.body.apps).toHaveLength(69); for (const slug of ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) { expect(res.body.apps.find((app: { slug: string }) => app.slug === slug).tags).toContain("model-provider"); } @@ -8027,6 +8064,43 @@ describeEmbeddedPostgres("tool access service", () => { } }); + it.each(["user", "organization"] as const)("builds one Workspace catalog from actual partial consent for a %s grant", async (grantKind) => { + const company = await createCompany(db); + const userId = `workspace-member-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all"); + const fullClaim = connector.claim; + connector.claim = vi.fn(async (input) => ({ ...await fullClaim(input), scopes: ["https://www.googleapis.com/auth/documents"] })); + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const actor = { actorType: "user" as const, actorId: userId }; + const provider = mockToolsList([ + { name: "read_doc", annotations: { readOnlyHint: true } }, + { name: "update_doc", annotations: { readOnlyHint: true } }, + { name: "delete_doc" }, + ]); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace", grantKind, + }, actor); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", actor, + }); + const completed = await service.completePaperclipCloudConnectorCallback({ + state: new URL(started.authorizationUrl).searchParams.get("state")!, claimId: "workspace-claim", actor, + }); + expect(completed.connection.status).toBe("active"); + expect(completed.catalog.map((tool) => tool.toolName).sort()).toEqual(["docs__read_doc", "docs__update_doc"]); + expect(completed.catalog.find((tool) => tool.toolName === "docs__update_doc")?.riskLevel).toBe("write"); + expect(provider.mock.calls.length).toBeGreaterThan(0); + expect(new Set(provider.mock.calls.map(([url]) => String(url)))).toEqual(new Set(["https://docsmcp.googleapis.com/mcp/v1"])); + const saved = await db.select().from(toolConnections).where(eq(toolConnections.companyId, company.id)); + expect(saved).toHaveLength(1); + const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId)); + expect(grants.find((grant) => grant.status === "active")?.providerTenant?.oauth?.scopes).toEqual(["https://www.googleapis.com/auth/documents"]); + const profiles = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); + expect(profiles).toHaveLength(1); + expect(JSON.stringify(completed)).not.toContain("workspace-access-token"); + }); + 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()}`; diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 2f0774be90c..1b122d43e5c 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -197,6 +197,7 @@ describeEmbeddedPostgres("tool gateway service", () => { await db.delete(issues); await db.delete(projects); await db.delete(agents); + await db.delete(companyMemberships); await db.delete(companies); }); @@ -1341,6 +1342,48 @@ describeEmbeddedPostgres("tool gateway service", () => { ]); }); + it.each([ + ["docs__read_doc", "documents", "docsmcp"], + ["gmail__get_message", "gmail.readonly", "gmailmcp"], + ["drive__search_files", "drive.readonly", "drivemcp"], + ["sheets__get_values", "spreadsheets", "sheetsmcp"], + ["slides__read_presentation", "presentations", "slidesmcp"], + ["calendar__list_events", "calendar.events", "calendarmcp"], + ["chat__list_messages", "chat.messages.readonly", "chatmcp"], + ["people__get_user_profile", "userinfo.profile", "people"], + ["search__search_corpus", null, "workspacemcp"], + ] as const)("routes %s and denies cached actions after consent changes", async (toolName, permission, host) => { + const { company, agent, run } = await createRunFixture(db); + await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: "board", status: "active", membershipRole: "owner" }); + const { connection, catalogEntry } = await createRemoteMcpToolFixture(db, company.id); + await db.update(toolConnections).set({ config: { sourceTemplateKey: "google-workspace", url: "https://workspacemcp.googleapis.com/mcp/v1" } }) + .where(eq(toolConnections.id, connection.id)); + const scopes = permission ? [`https://www.googleapis.com/auth/${permission}`] : ["gmail.readonly", "drive.readonly", "calendar.readonly", "chat.messages.readonly"].map((value) => `https://www.googleapis.com/auth/${value}`); + await db.update(connectionGrants).set({ providerTenant: { oauth: { scopes, scopeSource: "provider" } } }) + .where(eq(connectionGrants.connectionId, connection.id)); + await db.update(toolCatalogEntries).set({ name: toolName, toolName }).where(eq(toolCatalogEntries.id, catalogEntry.id)); + await db.insert(toolPolicies).values({ companyId: company.id, name: "Allow reads", policyType: "allow", selectors: { riskLevel: "read" } }); + const provider = vi.fn(async (_url, init) => { + const payload = JSON.parse(String(init.body)); + return Response.json({ jsonrpc: "2.0", id: payload.id, result: { content: [{ type: "text", text: "Test document." }] } }); + }); + const gateway = createTestToolGatewayService(db, { remoteHttpRequest: provider }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.upstreamToolName === toolName)!; + expect(tool).toBeTruthy(); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + expect(provider.mock.calls[0][0]).toBe(`https://${host}.googleapis.com/mcp/v1`); + expect(JSON.parse(String(provider.mock.calls[0][1].body)).params.name).toBe(toolName.split("__")[1]); + await db.update(connectionGrants).set({ providerTenant: { oauth: { scopes: [], scopeSource: "provider" } } }) + .where(eq(connectionGrants.connectionId, connection.id)); + expect((await gateway.listToolsForSession(session.token)).some((item) => item.upstreamToolName === toolName)).toBe(false); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })) + .rejects.toMatchObject({ reasonCode: "oauth_insufficient_scope" }); + await expect(gateway.executeTestCall({ companyId: company.id, connectionId: connection.id, agentId: agent.id, + userId: "board", toolName, parameters: {} })).resolves.toMatchObject({ error: { reasonCode: "oauth_insufficient_scope" } }); + expect(provider).toHaveBeenCalledTimes(1); + }); + it("hides cached Chat unread filters and blocks agent and board requests before provider dispatch", async () => { const { company, agent, run } = await createRunFixture(db); const { connection, catalogEntry } = await createRemoteMcpToolFixture(db, company.id); diff --git a/server/src/services/gmail-tool-governance.test.ts b/server/src/services/gmail-tool-governance.test.ts index 526f7b5b516..c2def44fb6b 100644 --- a/server/src/services/gmail-tool-governance.test.ts +++ b/server/src/services/gmail-tool-governance.test.ts @@ -21,6 +21,16 @@ describe("Gmail tool governance", () => { }); describe("Google Workspace tool governance", () => { + it("keeps combined-profile writes and destructive annotations behind their existing risk gates", () => { + expect(classifyRisk({ name: "docs__update_doc", annotations: { readOnlyHint: true } }, "google-workspace")).toBe("write"); + expect(classifyRisk({ name: "docs__update_doc", annotations: { destructiveHint: true } }, "google-workspace")).toBe("destructive"); + expect(classifyRisk({ name: "docs__read_doc" }, "google-workspace")).toBe("read"); + expect(classifyRisk({ name: "calendar__delete_event" }, "google-workspace")).toBe("destructive"); + expect(classifyRisk({ name: "chat__send_message" }, "google-workspace")).toBe("destructive"); + expect(isGoogleWorkspaceToolAllowed("workspace.all", { name: "gmail__send_message" })).toBe(false); + expect(isGoogleWorkspaceToolAllowed("workspace.all", { name: "chat__list_memberships" })).toBe(false); + }); + it("limits each capability profile to its reviewed reads and writes", () => { expect(isGoogleWorkspaceToolAllowed("drive.read", { name: "search_files" })).toBe(true); expect(isGoogleWorkspaceToolAllowed("drive.read", { name: "create_file" })).toBe(false); diff --git a/server/src/services/google-chat-tool-policy.test.ts b/server/src/services/google-chat-tool-policy.test.ts index 827c1eff12a..8daa872474b 100644 --- a/server/src/services/google-chat-tool-policy.test.ts +++ b/server/src/services/google-chat-tool-policy.test.ts @@ -8,6 +8,14 @@ import { const chat = { config: { sourceTemplateKey: "google-chat" } }; describe("Google Chat search without read-state permission", () => { + it("applies the same unread restriction to the combined connector without affecting Gmail", () => { + const workspace = { config: { sourceTemplateKey: "google-workspace" } }; + expect(() => assertGoogleChatToolArgumentsSupported(workspace, "chat__search_messages", { isUnread: false })) + .toThrow("read/unread filtering is not supported"); + expect(() => assertGoogleChatToolArgumentsSupported(workspace, "gmail__search_messages", { isUnread: false })).not.toThrow(); + expect(googleChatToolInputSchema(workspace, "chat__search_messages", { type: "object", properties: { isUnread: { type: "boolean" } } })) + .toEqual({ type: "object", properties: {} }); + }); it.each([true, false, null, "true"])("rejects explicit unread filters (%s)", (isUnread) => { for (const tool of ["search_messages", "chat.searchMessages", "google/chat/search-messages"]) { for (const args of [ diff --git a/server/src/services/google-chat-tool-policy.ts b/server/src/services/google-chat-tool-policy.ts index 4811e33bcd3..a0e40b41f60 100644 --- a/server/src/services/google-chat-tool-policy.ts +++ b/server/src/services/google-chat-tool-policy.ts @@ -1,4 +1,5 @@ import { badRequest } from "../errors.js"; +import { googleWorkspaceToolTarget } from "@paperclipai/shared"; type Connection = { config: Record }; @@ -9,6 +10,10 @@ function record(value: unknown): Record { } function isChatSearch(connection: Connection, toolName: string): boolean { + if (connection.config.sourceTemplateKey === "google-workspace") { + const target = googleWorkspaceToolTarget(toolName); + return target?.service === "chat" && target.leaf === "search_messages"; + } const leaf = (toolName.split(/[.:/]/).pop() ?? toolName) .replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase(); if (leaf !== "search-messages") return false; diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 307c9262b51..3e6f73bdc74 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -54,6 +54,22 @@ function config() { } describe("Paperclip Cloud connector", () => { + it("accepts actual partial Workspace consent inside an envelope bound to the full requested profile", async () => { + const keys = config(); + const scopes = ["https://www.googleapis.com/auth/documents"]; + const credentials = { v: 1, accessToken: "test-access", refreshToken: "test-refresh", tokenType: "Bearer", + accessTokenExpiresAt: null, refreshTokenExpiresAt: null, scopes, subject, companyId, instanceId, + environment: keys.config.environment, provider: "google", profile: "workspace.all" }; + let sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "workspace.all"); + 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); + 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" }); + } + }); async function rejection(response: Response) { const connector = createPaperclipCloudConnector({ config: config().config, @@ -633,7 +649,7 @@ function seal( "sha256", diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipientPublicKey }), Buffer.concat([ephemeralRaw, recipientRaw]), - aad, + profile === "workspace.all" ? createHash("sha256").update(aad).digest() : aad, 32, )); const iv = randomBytes(12); diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index fb52bed38ca..f062245845e 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -17,6 +17,7 @@ import { GOOGLE_WORKSPACE_CONNECTOR_PROFILES, isGitHubConnectorProfileId, isGoogleWorkspaceConnectorProfileId, + isGoogleWorkspaceScopeGrant, type GitHubConnectorProfileId, type GoogleWorkspaceConnectorProfileId, } from "@paperclipai/shared"; @@ -357,7 +358,7 @@ export function createPaperclipCloudConnector(input: { if (credentials.profile !== profile) { throw new PaperclipCloudConnectorError("Paperclip Cloud connector profile binding did not match", "CONNECTOR_BINDING_MISMATCH"); } - if (!sameStringSet(credentials.scopes, definition.scopes)) { + if (!(profile === "workspace.all" ? isGoogleWorkspaceScopeGrant(credentials.scopes) : sameStringSet(credentials.scopes, definition.scopes))) { throw new PaperclipCloudConnectorError("Paperclip Cloud scope grant did not match", "REAUTHORIZATION_REQUIRED"); } return credentials; @@ -724,7 +725,10 @@ function decryptEnvelope( "sha256", diffieHellman({ privateKey: recipientPrivateKey, publicKey: ephemeralKey }), Buffer.concat([ephemeralRaw, recipientRaw]), - aad, + // The combined profile's scope binding exceeds Node's 1024-byte HKDF-info + // limit. Its new wire contract hashes that binding; legacy profiles retain + // their original derivation. GCM still authenticates the complete AAD. + provider === "google" && profile === "workspace.all" ? createHash("sha256").update(aad).digest() : aad, 32, )); const iv = Buffer.from(envelope.iv, "base64url"); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index d5da95b5850..3ebdca23012 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -6,6 +6,7 @@ import type { ComposioAppAccount, ComposioAppAccountInput, ComposioAppSetupInput import { composioAppAccounts, composioAppSetupResult } from "./composio-app-setup.js"; import { honchoManagedArguments } from "./honcho-connection.js"; import { defaultConnectionAgentInstructions } from "@paperclipai/shared"; +import { GOOGLE_WORKSPACE_SERVICES, googleWorkspaceServiceGranted, googleWorkspaceToolName, googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted, isGoogleWorkspaceScopeGrant, type GoogleWorkspaceService } from "@paperclipai/shared"; import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { BROWSER_USE_TOOLS } from "@paperclipai/shared"; @@ -2368,6 +2369,14 @@ export function classifyRisk( tool: McpToolDescriptor, sourceTemplateKey?: string | null, ): ToolRiskLevel { + if (sourceTemplateKey === "google-workspace") { + const target = googleWorkspaceToolTarget(tool.name); + if (!target) return "destructive"; + const risk = classifyRisk({ ...tool, name: target.upstreamName }, target.appSlug); + if (risk === "destructive" || target.leaf === "delete_event" || target.leaf === "send_message") return "destructive"; + if (target.write) return "write"; + return risk; + } const annotations = tool.annotations ?? {}; if (annotations.destructiveHint === true || annotations.destructive === true) return "destructive"; @@ -2519,6 +2528,7 @@ export function isGoogleWorkspaceToolAllowed( profileId: GoogleWorkspaceConnectorProfileId, tool: McpToolDescriptor, ): boolean { + if (profileId === "workspace.all") return googleWorkspaceToolTarget(tool.name) !== null; const profile = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profileId]; const toolName = googleWorkspaceToolLeafName(tool.name); const readTools = @@ -7111,13 +7121,31 @@ export function toolAccessService( connection: typeof toolConnections.$inferSelect, credentialHeaders?: Record, actor?: ActorInfo, + workspaceEndpoint?: string, ): Promise { assertSupportedConnection(connection); let headers = credentialHeaders ?? { ...projectedConnectionHeaders(connection), ...(await resolveCredentialHeaders(connection, actor)), }; - const endpoint = await resolvedRemoteEndpoint(connection, actor); + if (connection.config.sourceTemplateKey === "google-workspace" && !workspaceEndpoint) { + const grant = await vaultGrantForConnection(connection, actor); + const scopes = googleWorkspaceGrantedScopes(grant); + 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 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)); + })); + // 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 endpoint = workspaceEndpoint ?? await resolvedRemoteEndpoint(connection, actor); // Pinned to the address the guard approved: `config.url` is operator-supplied, // so a second DNS resolution here would reopen the rebinding window that // PAP-17098 closed for the OAuth endpoints. @@ -11730,6 +11758,12 @@ export function toolAccessService( throw error; } + if (connection.config.sourceTemplateKey === "google-workspace" + && !isGoogleWorkspaceScopeGrant(normalizeOauthScopes(token.scope))) { + await db.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: now() }) + .where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, connection.companyId))); + throw unprocessable("Google did not return verifiable permissions. Reconnect Google Workspace.", { code: "oauth_reauthorization_required" }); + } let nextRefreshRef: ToolCredentialSecretRef | null = null; if (token.refreshToken) { nextRefreshRef = await createOrRotateOAuthSecret({ @@ -14913,6 +14947,8 @@ export function toolAccessService( // against which it could safely judge the caller's requested scope. if (authorizationScopes.length > 0) authorizationUrl.searchParams.set("scope", authorizationScopes.join(" ")); + if (galleryMethod && connection.config.sourceTemplateKey === "google-workspace") + authorizationUrl.searchParams.set("enable_granular_consent", "true"); const reviewedAuthorizationParams = galleryMethod?.defaults?.oauthAuthorizationParams; if (reviewedAuthorizationParams?.access_type) @@ -16153,6 +16189,9 @@ export function toolAccessService( code: input.code, resource: endpoints.resource, }); + if (sourceTemplateKey === "google-workspace" && !isGoogleWorkspaceScopeGrant(normalizeOauthScopes(token.scope))) { + throw unprocessable("Google did not return a supported permission grant. Reconnect and choose the Google services you want to use.", { code: "oauth_scope_grant_invalid" }); + } const connectedAt = now(); const expiresAt = token.expiresIn ? new Date(connectedAt.getTime() + token.expiresIn * 1000).toISOString() diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 3e56275d205..b27a0af07bf 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -1,4 +1,5 @@ import { composeConnectionInstructions } from "./connection-instructions.js"; +import { googleWorkspaceToolTarget, googleWorkspaceGrantedScopes, isGoogleWorkspaceToolGranted } from "@paperclipai/shared"; import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js"; import { boundedMcpToolName } from "./mcp-tool-names.js"; import { browserUseService } from "./browser-use.js"; @@ -2918,7 +2919,7 @@ export function createToolGatewayService( async function searchableOnDemandTools( session: ToolGatewaySession, ): Promise { - const tools = (await connectedMcpToolsForCompany(session.companyId)).filter( + const tools = (await connectionToolsForContext(session)).filter( isOnDemandRemoteTool, ); const decisions = await decideToolsForListing(tools, (tool) => @@ -2989,8 +2990,21 @@ export function createToolGatewayService( /** Shared connection descriptors and task restrictions; no provider calls. */ async function connectionToolsForContext(session: ToolGatewaySession): Promise { const guestBotConnection = await githubGuestBotConnectionForSession(db, session); - const connectedTools = (await connectedMcpToolsForCompany(session.companyId)) + let connectedTools = (await connectedMcpToolsForCompany(session.companyId)) .filter(tool => !guestBotConnection || !tool.connectionId || (tool.connectionId === guestBotConnection && tool.providerType === "paperclip_github_chat")); + const connectionIds = [...new Set(connectedTools.flatMap((tool) => tool.connectionId ? [tool.connectionId] : []))]; + if (connectionIds.length) { + const connections = await db.select().from(toolConnections).where(and( + eq(toolConnections.companyId, session.companyId), inArray(toolConnections.id, connectionIds), + )); + for (const connection of connections.filter((item) => item.config.sourceTemplateKey === "google-workspace")) { + let scopes: string[] = []; + try { scopes = googleWorkspaceGrantedScopes(await resolveConnectionGrant(session, connection, false)); } + catch { /* Missing identity or consent never contributes tools. */ } + connectedTools = connectedTools.filter((tool) => tool.connectionId !== connection.id + || isGoogleWorkspaceToolGranted(tool.upstreamToolName ?? "", scopes)); + } + } return [ ...await githubBotToolsForSession(db, session), ...await slackToolsForSession(db, session), @@ -5926,7 +5940,14 @@ export function createToolGatewayService( const data = await browserUseService(db, options.remoteHttpRequest).execute(session, grant, invocationId!, entry.toolName, parameters); return { result: { content: JSON.stringify(data), data: { structuredContent: data, transport: "rest_api" } } }; } - const endpoint = await resolvedRemoteEndpoint(session, connection, grant); + const workspaceTarget = connection.config.sourceTemplateKey === "google-workspace" + ? googleWorkspaceToolTarget(entry.toolName) : null; + if (connection.config.sourceTemplateKey === "google-workspace" + && (!workspaceTarget || !isGoogleWorkspaceToolGranted(entry.toolName, googleWorkspaceGrantedScopes(grant)))) { + throw new ToolGatewayHttpError(403, "This Google action needs a permission you have not granted. Reconnect Google Workspace to change access.", "oauth_insufficient_scope"); + } + const endpoint = workspaceTarget?.serverUrl ?? await resolvedRemoteEndpoint(session, connection, grant); + const upstreamToolName = workspaceTarget?.upstreamName ?? entry.toolName; // Method-defined headers are trusted catalog configuration. Treat them as // managed headers so callers cannot override the scope that was reviewed // during tools/list. Credentials remain authoritative on collisions. @@ -5951,7 +5972,7 @@ export function createToolGatewayService( endpoint: auditSafeEndpoint(endpoint), mcpMethod: "tools/call", requestId, - upstreamToolName: entry.toolName, + upstreamToolName, dispatched: true, }, }; @@ -5962,8 +5983,19 @@ export function createToolGatewayService( // identity's session and leaves other agents on the connection alone. let mcpSession: { scope: string; headers: Record; sessionId: string } | undefined; try { - const dispatchRemote = (target: string, init: RequestInit) => - options.remoteHttpRequest + const dispatchRemote = async (target: string, init: RequestInit) => { + // Refresh can reduce consent. Recheck the selected grant after refreshing + // credentials and before every dispatch (including an authorization retry). + if (workspaceTarget) { + const [currentGrant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, connection.companyId), + eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"), + )); + if (!currentGrant || !isGoogleWorkspaceToolGranted(entry.toolName, googleWorkspaceGrantedScopes(currentGrant))) { + throw new ToolGatewayHttpError(403, "This Google action is not covered by the current permission grant.", "oauth_insufficient_scope"); + } + } + return options.remoteHttpRequest ? options.remoteHttpRequest(target, init) : guardedRemoteHttpFetch(target, init, { ...remoteHttpFetchOptions(), @@ -5972,6 +6004,7 @@ export function createToolGatewayService( // letting the tighter default cut a legitimately slow tool short. responseTimeoutMs: ms, }); + }; if (isRailwayEndpoint(connection.config.url) && normalizeRailwayToolName(entry.toolName).startsWith(RAILWAY_TOOL_PREFIX)) { if (!isRailwayConnection(connection) || connection.config.railwayApiStatus !== "available") { throw new ToolGatewayHttpError(422, "Railway API access is not verified. Refresh actions or reconnect this Railway connection.", "railway_api_not_verified"); @@ -6034,7 +6067,7 @@ export function createToolGatewayService( id: requestId, method: "tools/call", params: { - name: entry.toolName, + name: upstreamToolName, arguments: parameters, }, }), diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index c981650dc9b..8dc929e7d38 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -223,6 +223,13 @@ "workspace search" ] }, + { + "slug": "google-workspace", + "provider": "Google Workspace", + "catalogVisible": true, + "localAsset": "/brands/apps/google-people.svg", + "aliases": ["google suite", "g suite"] + }, { "slug": "hugging-face", "provider": "Hugging Face", diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index eea8a1fc6fc..80b715fd514 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -1807,7 +1807,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { "preserves personal Workspace access for $slug when changing method (enrollment return: $enrollmentReturn)", async ({ slug, enrollmentReturn }) => { const definition = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug)!; - const readMethod = definition.methods.find((method) => method.key === "paperclip-read")!; + const readMethod = definition.methods.find((method) => method.key === "paperclip-read" || method.key === "paperclip-workspace")!; mockSearch.value = enrollmentReturn ? `source=${slug}&stage=setup&cloud_connector=enrolled` : `source=${slug}`; @@ -1828,7 +1828,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await flushReact(); } - if (definition.methods.some((method) => method.capabilityProfile?.key !== "read")) { + if (definition.methods.some((method) => method.capabilityProfile?.key !== readMethod.capabilityProfile?.key)) { const readChoice = radioContaining(readMethod.capabilityProfile!.label); expect(readChoice).not.toBeNull(); await act(async () => { @@ -1866,7 +1866,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ - galleryKey: slug, connectionMethodKey: "paperclip-read", grantKind: "user", + galleryKey: slug, connectionMethodKey: readMethod.key, grantKind: "user", })); }); @@ -1909,6 +1909,29 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).toContain("needs its own OAuth app"); }); + it("connects Google Workspace using one profile without separate service choices", async () => { + mockSearch.value = "source=google-workspace&stage=setup"; + const workspace = getAppStoreDefinition("google-workspace")!; + listGalleryMock.mockResolvedValue({ apps: [{ + ...workspace, ownershipAvailability: { ...workspace.ownershipAvailability, platform_shared: true }, + }] }); + await render(); + + expect(container.textContent).toContain("Google Workspace"); + expect(container.textContent).not.toContain("What should Paperclip be able to do?"); + await openAccessAdvanced(); + expect(buttonByText("Use your own Google OAuth app")).toBeDefined(); + expect(buttonByText("Continue to sign in")?.disabled).toBe(false); + await act(async () => { + buttonByText("Continue to sign in")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(connectAppMock).toHaveBeenCalledTimes(1); + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ + galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace", + })); + }); + it("renders Google Calendar as one minimal, unboxed setup screen", async () => { mockParams.appKey = "google-calendar"; listGalleryMock.mockResolvedValue({ apps: [GOOGLE_CALENDAR] }); diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 0961c1d20b1..dd0f1a29885 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -808,6 +808,7 @@ describe("Connectors landing page", () => { }); const googleSlugs = [ + "google-workspace", "gmail", "google-drive", "google-docs", "google-sheets", "google-slides", "google-calendar", "google-chat", "google-people", "google-workspace-search", ]; diff --git a/ui/storybook/stories/google-workspace.stories.tsx b/ui/storybook/stories/google-workspace.stories.tsx new file mode 100644 index 00000000000..532b3b51ab9 --- /dev/null +++ b/ui/storybook/stories/google-workspace.stories.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { getAppStoreDefinition } from "@paperclipai/shared"; +import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow"; +import { useNavigate } from "@/lib/router"; + +const workspace = getAppStoreDefinition("google-workspace")!; +const noop = () => {}; + +const meta = { + title: "Apps/Connections/Google Workspace", + parameters: { layout: "fullscreen" }, + beforeEach: () => { + const original = window.fetch; + window.fetch = async (input, init) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); + if (url.pathname.endsWith("/tools/gallery")) return Response.json({ + apps: [{ ...workspace, ownershipAvailability: { ...workspace.ownershipAvailability, platform_shared: true } }], + capabilities: { canCreateOrganizationGrant: true, canSetCompanyInstall: true }, + }); + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + if (url.pathname.startsWith("/api/") && !["GET", "HEAD"].includes(method.toUpperCase())) { + return Response.json({ error: "Preview only: no Google authorization was started." }, { status: 422 }); + } + return original(input, init); + }; + return () => { window.fetch = original; }; + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +function Setup() { + const navigate = useNavigate(); + const [ready, setReady] = useState(false); + useEffect(() => { + navigate("/apps/connect?source=google-workspace", { replace: true }); + setReady(true); + }, [navigate]); + return
+

Production setup UI with simulated account data. Google consent and provider calls are not simulated as successful.

+ {ready && } +
; +} + +export const Connect: Story = { render: () => }; +export const Light: Story = { ...Connect, globals: { theme: "light" } }; +export const Mobile: Story = { ...Connect, globals: { viewport: { value: "mobile", isRotated: false } } };