From 23b2ceb0d19d4dff0e34f0d957aa41efec739816 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:07:30 -0500 Subject: [PATCH] feat(connections): add unified Google Workspace connector (#15545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Connected Apps supply governed tools to those agents. > - Google services currently require separate connections or one broad request. > - Users need one account and control over which services it can access. > - A picker must change the OAuth request, not just the screen. > - This pull request binds selected services to consent and tool access. > - The benefit is simpler setup without enabling unselected tools. ## Linked Issues or Issue Description **Subsystem affected** Connections catalog, OAuth grants, and the MCP tool gateway. **Current behavior** Google setup uses separate service profiles. A combined request alone cannot express the user's choice before Google consent. Accumulated Google grants can be broader than a new request. **Proposed behavior** Offer one Workspace connection. Let users choose Gmail, Drive (including Docs, Sheets, and Slides), and Calendar. Start all three with read and write access. Let users disable groups or choose read-only access. Do not offer Chat, Contacts/directory, or cross-service Search in new setup. Bind selected scopes to durable OAuth state. Cap effective tool access to both the saved request and provider-reported consent. **Reason and benefit** Users can choose services without managing several accounts. Existing company, identity, agent, and action-policy controls remain in force. **Breaking changes** No database migration. Existing service connections and grants remain supported. New Workspace requests require the companion broker change in https://github.com/paperclipai/paperclip-cloud/pull/823. Old brokers reject narrowed requests safely. Related work: #14740 reduced Google scopes; #14774 introduced the earlier catalog hold. #15412 proposes a separate Gmail REST implementation. This maintainer-requested change extends the existing Connections and governed tool gateway paths. It does not replace them. ## What Changed - Add one Workspace catalog entry and a namespaced action catalog. Retain legacy definitions and saved accounts. - Use the approved production service picker in Apps and task-hosted setup. Keep dropdowns between names and toggles. Block empty selection. - Derive exact scopes from a closed registry. Default selection requests nine scopes. New requests exclude Chat, Contacts/directory, and Search scopes. - Bind selected scopes through managed and customer-owned OAuth state, callback, claim redemption, and refresh. - Cap accumulated Google grants to the saved selection. Recheck grant-specific permission at discovery, listing, and dispatch, including cached actions. - Restore the current subject's saved request on reconnect. Block on grant-load failure. Preserve draft selection on cancel and retry. Do not create an account on picker cancellation. - Preserve healthy services during partial outages, without combining grants across identities. Keep writes from being automatically replayed. - Preserve the original 21-scope legacy Workspace envelope contract as individual profiles evolve. - Add production-component Storybook stories, request validators, consent regression tests, and rollout guidance. Integrate the stories with the current feature catalog while preserving their IDs. ## Verification - Integrated branch merges current master and has no merge conflicts. - Full workspace build passed on the integrated checkout. - Token gates passed. Storybook catalog structure check passed: 283 files and 2,750 stories. - Final affected app suites: 914 passed. This includes 502 database-backed service/gateway tests and 412 UI/shared/validator/broker-handoff/governance tests. The production picker tests cover defaults, identity-specific reconnect, and failed-load recovery. - Full workspace typecheck passed after adding required current-master Storybook annotations. The final UI build also passed. - The full repository suite is still running and includes the two pre-fix retry assertions. Both were updated for the current gateway's explicit-retry contract and pass in the clean 502-test database run. No complete final-head full-suite pass is claimed. - The initial local database startup failure came from missing native-library symlinks in the fresh dependency install. The package's postinstall repair fixed it; the clean database-backed run has no skipped tests. - Broker focused suite: 47 passed, including real signed session → callback → claim redemption and wrong-scope rejection. Full broker suite before the final test-only follow-up: 3,562 passed, 103 skipped. - Real integrated-app walkthrough: Connectors → Workspace → customer-owned app → service picker. Verified all three defaults, empty-selection blocking, cancel/reopen with the selected read-only access preserved, preserved form values, and zero saved connections after cancel. Credentials were synthetic; no Google grant was made. - Current-head CI and Greptile review remain pending. Previous-head results are not treated as current. ## Risks Ship the companion broker before app instances send narrowed requests. Enable the intended Workspace client/profile separately on the target broker. This pull request does not deploy a tenant app, start a fleet rollout, alter Google Console, or upgrade the pinned reviewer stack. The three-group picker is not proof of live provider access. Before production, verify a real selected-service grant, reconnect, refresh, allowed reads/writes, and denied unselected actions on the isolated staging reviewer app. Google verification and preview enrollment remain separate requirements. Legacy service profiles stay compatible. New grant metadata keeps provider scope evidence separate from requested scope evidence. Missing or unsupported evidence fails closed. No scope union across user or organization grants is allowed. ## Model Used OpenAI Codex, GPT-5-based coding agent, with repository editing, code execution, and browser tools. The exact serving model ID and context-window size were not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal issue ID - [ ] I have run all final-head tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../GOOGLE-WORKSPACE-SERVICE-CONSENT.md | 43 ++ doc/connections/GOOGLE-WORKSPACE.md | 122 ++++-- .../tool-method-permission-reviews.json | 60 +++ ...0-08-unified-google-workspace-connector.md | 39 ++ .../shared/src/app-definitions-url.test.ts | 1 + .../shared/src/app-definitions.generated.ts | 45 ++- packages/shared/src/app-definitions.test.ts | 4 +- packages/shared/src/app-definitions.ts | 1 + .../src/app-definitions/google-workspace.json | 121 ++++++ .../src/google-workspace-connectors.test.ts | 87 ++++ .../shared/src/google-workspace-connectors.ts | 137 ++++++- .../shared/src/validators/tool-access.test.ts | 8 + packages/shared/src/validators/tool-access.ts | 11 + scripts/ingest-app-definitions.mjs | 6 +- .../src/__tests__/tool-access-service.test.ts | 377 ++++++++++++++++++ .../__tests__/tool-gateway-service.test.ts | 42 ++ server/src/routes/tool-access.ts | 2 + .../services/gmail-tool-governance.test.ts | 10 + .../services/google-chat-tool-policy.test.ts | 8 + .../src/services/google-chat-tool-policy.ts | 5 + .../paperclip-cloud-connector.test.ts | 39 +- .../src/services/paperclip-cloud-connector.ts | 24 +- server/src/services/tool-access.ts | 167 +++++++- server/src/services/tool-gateway.ts | 70 +++- ui/public/brands/apps/manifest.json | 7 + ui/src/api/tools.ts | 1 + .../connections/ConnectionSetupFlow.tsx | 49 ++- .../GoogleWorkspaceServices.test.tsx | 86 ++++ .../connections/GoogleWorkspaceServices.tsx | 78 ++++ ui/src/pages/apps/AppsConnect.test.tsx | 49 ++- ui/src/pages/apps/Browse.test.tsx | 34 +- ui/src/pages/apps/Browse.tsx | 25 +- .../google-workspace/ServiceConsent.test.ts | 35 ++ .../google-workspace/ServiceConsent.tsx | 71 ++++ .../google-workspace-services.stories.tsx | 65 +++ .../google-workspace.stories.tsx | 53 +++ 36 files changed, 1847 insertions(+), 135 deletions(-) create mode 100644 doc/connections/GOOGLE-WORKSPACE-SERVICE-CONSENT.md create mode 100644 doc/plans/2026-10-08-unified-google-workspace-connector.md create mode 100644 packages/shared/src/app-definitions/google-workspace.json create mode 100644 packages/shared/src/google-workspace-connectors.test.ts create mode 100644 ui/src/features/connections/GoogleWorkspaceServices.test.tsx create mode 100644 ui/src/features/connections/GoogleWorkspaceServices.tsx create mode 100644 ui/storybook/prototypes/google-workspace/ServiceConsent.test.ts create mode 100644 ui/storybook/prototypes/google-workspace/ServiceConsent.tsx create mode 100644 ui/storybook/stories/connection-setup/google-workspace-services.stories.tsx create mode 100644 ui/storybook/stories/connection-setup/google-workspace.stories.tsx diff --git a/doc/connections/GOOGLE-WORKSPACE-SERVICE-CONSENT.md b/doc/connections/GOOGLE-WORKSPACE-SERVICE-CONSENT.md new file mode 100644 index 00000000000..143fbc273cb --- /dev/null +++ b/doc/connections/GOOGLE-WORKSPACE-SERVICE-CONSENT.md @@ -0,0 +1,43 @@ +# Google Workspace service consent + +New Workspace setup uses one account connection and three service groups: +Gmail, Drive (including Docs, Sheets, and Slides), and Calendar. Every group +starts enabled with read and write access. The picker can disable groups or +choose read-only access. Chat, Contacts/directory, and cross-service search are +not offered. Existing individual-service connections remain compatible. + +`GoogleWorkspaceServicesDialog` is shared by Apps and task-hosted setup. +It appears before creating an OAuth draft or starting reconnect. Cancel does +not create a connection. Reconnect reads the current subject's grant and +restores its saved request; failure to load never substitutes broader defaults. +Account-page reconnect calls without a new selection also restore the exact +subject's saved request on the server. Missing request/grant evidence blocks +reconnect until a service choice is supplied; new-account defaults never replace it. +The same service fields appear in Storybook's **Google Workspace → Service +consent preview → Production picker**. Storybook callbacks do not grant access. + +The `workspaceServices` input accepts only nonempty combinations of `gmail`, +`drive`, and `calendar`, each `read` or `write`. The server derives exact scopes +from the shared closed registry. Default write access requests nine scopes. +Drive file creation is limited to app-opened/app-created files; Docs, Sheets, +and Slides have their respective editor scopes. Gmail uses `gmail.compose` +for draft creation; Google permits sending under that scope, but Paperclip's +closed tool allowlist still excludes sending. + +Both customer OAuth and managed Cloud OAuth bind the request to durable OAuth +state. The broker must support nonempty `workspace.all` scope subsets, preserve +them in the provider URL and encrypted claim binding, and reject unknown scopes. +Other managed profiles retain their exact-set contract. Ship that broker change +before updating tenant apps; an older broker rejects narrowed requests safely. + +Only provider-reported consent enables tools. Callback and refresh cap effective +permissions to the saved request, including read-only implications of broader +Google grants. Accumulated grants cannot enable unselected services or writes. +Credential ownership, agent installations, action policies, company boundaries, +and single-use state remain under their existing enforcement paths. + +Production readiness additionally requires the intended OAuth client and +`workspace.all` profile to be enabled on the target broker, a deployed app and +broker with matching contracts, and live reviewer-path verification. Passing +Storybook or fixture-backed OAuth tests does not establish Google verification +approval, production deployment, or a successful live grant. diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index ff76addd514..993a8cf31b7 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -1,40 +1,92 @@ # 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. Paperclip first offers Gmail, Drive (including +Docs, Sheets, and Slides), and Calendar. All three start enabled with read and +write access. Users can disable a group or choose read-only access. New setup +does not offer Chat, Contacts/directory, or cross-service Search. -1. Gmail -2. Google Drive -3. Google Docs -4. Google Sheets -5. Google Slides -6. Google Calendar -7. Google Chat -8. Google People -9. Google Workspace Search +The combined connection has one credential grant, capability catalog, policy +profile, audit trail and reconnect lifecycle. It requests only the selected +groups' scopes (nine scopes for the default selection), not the legacy full +union. Google's consent screen also 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. -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. +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. Because +the catalog is shared, actions skipped because of narrower consent are preserved +when another active grant supports them. Failed services and tools removed from a +successful discovery are still disabled. Each caller's listing and dispatch use only their +selected grant, without combining permissions from different identities. +Individual call failures are reported and audited without marking unrelated +Google services unhealthy; they do not trigger automatic write retries. + +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. -## Temporary Connections page visibility hold +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. -While Google OAuth verification is pending, the Connections landing page -(`ui/src/pages/apps/Browse.tsx`) hides all nine Google Workspace 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. +## Combined profile deployment and verification -Keep verification instances pinned to their pre-hold app release so reviewers -can still find and test the integrations. After approval, remove the page's -`GOOGLE_CONNECTOR_SLUGS` filter and update its visibility tests before upgrading -those instances. Do not disable the shared definitions or broker profiles to -control this page's visibility. +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 selected request; +the legacy 21-scope profile remains supported for old clients. 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). + +## Connections catalog and rollout + +The Connections page offers one Google Workspace card for new accounts. Saved +individual-service accounts stay visible and retain their management routes. +This replaces the earlier display-only Google visibility hold. Showing the card +does not enable managed OAuth: signed instance-specific broker availability +still controls it. A customer-owned OAuth app remains a separate setup method. + +Do not upgrade or unpin existing verification instances as an implementation +step. Qualify the selected-scope flow on an isolated reviewer staging app first. +See [service consent](GOOGLE-WORKSPACE-SERVICE-CONSENT.md) for the app/broker +deployment order and live verification gates. ## Developer Preview enrollment @@ -60,7 +112,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 +126,11 @@ 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 asks for service groups before Google +consent. The endpoint table also documents legacy connections; it is not the +new-account service list. +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 +145,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 +160,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 eaa8114a565..b22e6554de4 100644 --- a/doc/connections/tool-method-permission-reviews.json +++ b/doc/connections/tool-method-permission-reviews.json @@ -2,6 +2,66 @@ "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" + ], + "capability": "write", + "supportedActions": "One grant for selected Gmail, Drive (Docs, Sheets, Slides), and Calendar services. The list is the read/write selection allowlist, not the default request; default write selections request nine scopes. Chat, People, and Search are excluded. Gmail sending remains unsupported.", + "evidence": [ + "https://developers.google.com/workspace/guides/configure-mcp-servers", + "https://developers.google.com/identity/protocols/oauth2/resources/granular-permissions" + ], + "reviewedAt": "2026-10-19", + "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" + ], + "capability": "write", + "supportedActions": "One grant for selected Gmail, Drive (Docs, Sheets, Slides), and Calendar services. The list is the read/write selection allowlist, not the default request; default write selections request nine scopes. Chat, People, and Search are excluded. Gmail sending remains unsupported.", + "evidence": [ + "https://developers.google.com/workspace/guides/configure-mcp-servers", + "https://developers.google.com/identity/protocols/oauth2/resources/granular-permissions" + ], + "reviewedAt": "2026-10-19", + "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-url.test.ts b/packages/shared/src/app-definitions-url.test.ts index 1d9711a5d8b..7a8e327ceae 100644 --- a/packages/shared/src/app-definitions-url.test.ts +++ b/packages/shared/src/app-definitions-url.test.ts @@ -11,6 +11,7 @@ describe("tool app gallery URL matching", () => { expect(getAppDefinitionForUrl("https://github.com/paperclipai/paperclip/pull/1")?.slug).toBe("github"); expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets"); expect(getAppDefinitionForUrl("https://gmailmcp.googleapis.com/mcp/v1")?.slug).toBe("gmail"); + expect(getAppDefinitionForUrl("https://workspace.google.com/")?.slug).toBe("google-workspace"); expect(getAppDefinitionForUrl("https://mcp.neon.tech/mcp")?.slug).toBe("neon"); expect(getAppDefinitionForUrl("https://app.withgauge.com/mcp")?.slug).toBe("gauge"); expect(getAppDefinitionForUrl("https://www.superagent.sh/mcp")?.slug).toBe("superagent"); diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index 1666fb5c280..467412e6ea1 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -81,26 +81,27 @@ import a79 from "./app-definitions/windsor-ai.json" with { type: "json" }; import a80 from "./app-definitions/gsc-wizard.json" with { type: "json" }; import a81 from "./app-definitions/clickup.json" with { type: "json" }; import a82 from "./app-definitions/klaviyo.json" with { type: "json" }; -import a83 from "./app-definitions/gmail.json" with { type: "json" }; -import a84 from "./app-definitions/google-drive.json" with { type: "json" }; -import a85 from "./app-definitions/google-docs.json" with { type: "json" }; -import a86 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a87 from "./app-definitions/google-slides.json" with { type: "json" }; -import a88 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a89 from "./app-definitions/google-chat.json" with { type: "json" }; -import a90 from "./app-definitions/google-people.json" with { type: "json" }; -import a91 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a92 from "./app-definitions/openai.json" with { type: "json" }; -import a93 from "./app-definitions/openrouter.json" with { type: "json" }; -import a94 from "./app-definitions/xai.json" with { type: "json" }; -import a95 from "./app-definitions/google.json" with { type: "json" }; -import a96 from "./app-definitions/bedrock.json" with { type: "json" }; -import a97 from "./app-definitions/responses-api.json" with { type: "json" }; -import a98 from "./app-definitions/messages-api.json" with { type: "json" }; -import a99 from "./app-definitions/chat-completions-api.json" with { type: "json" }; -import a100 from "./app-definitions/local.json" with { type: "json" }; -import a101 from "./app-definitions/daytona.json" with { type: "json" }; -import a102 from "./app-definitions/monday.json" with { type: "json" }; -import a103 from "./app-definitions/speko.json" with { type: "json" }; +import a83 from "./app-definitions/google-workspace.json" with { type: "json" }; +import a84 from "./app-definitions/gmail.json" with { type: "json" }; +import a85 from "./app-definitions/google-drive.json" with { type: "json" }; +import a86 from "./app-definitions/google-docs.json" with { type: "json" }; +import a87 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a88 from "./app-definitions/google-slides.json" with { type: "json" }; +import a89 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a90 from "./app-definitions/google-chat.json" with { type: "json" }; +import a91 from "./app-definitions/google-people.json" with { type: "json" }; +import a92 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a93 from "./app-definitions/openai.json" with { type: "json" }; +import a94 from "./app-definitions/openrouter.json" with { type: "json" }; +import a95 from "./app-definitions/xai.json" with { type: "json" }; +import a96 from "./app-definitions/google.json" with { type: "json" }; +import a97 from "./app-definitions/bedrock.json" with { type: "json" }; +import a98 from "./app-definitions/responses-api.json" with { type: "json" }; +import a99 from "./app-definitions/messages-api.json" with { type: "json" }; +import a100 from "./app-definitions/chat-completions-api.json" with { type: "json" }; +import a101 from "./app-definitions/local.json" with { type: "json" }; +import a102 from "./app-definitions/daytona.json" with { type: "json" }; +import a103 from "./app-definitions/monday.json" with { type: "json" }; +import a104 from "./app-definitions/speko.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,a91,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103] 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,a92,a93,a94,a95,a96,a97,a98,a99,a100,a101,a102,a103,a104] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 6cc868cf6ce..50cf0b0e572 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -884,7 +884,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(81); + expect(APP_STORE_DEFINITIONS).toHaveLength(82); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); @@ -956,7 +956,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 40d56a9453a..d88f8509157 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -33,6 +33,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..68e401fa299 --- /dev/null +++ b/packages/shared/src/app-definitions/google-workspace.json @@ -0,0 +1,121 @@ +{ + "schemaVersion": 1, + "slug": "google-workspace", + "name": "Google Workspace", + "description": "Connect Gmail, Drive, Docs, Sheets, Slides, and Calendar with one Google account.", + "categories": [ + "productivity", + "communication", + "content" + ], + "featured": true, + "branding": { + "logoUrl": "/brands/apps/google-people.svg" + }, + "urlPatterns": [ + "https://workspace.google.com/*" + ], + "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 services and read or write access before signing in." + }, + "grantKinds": [ + "user", + "organization" + ], + "ownershipModes": [ + "platform_shared" + ], + "whenToUse": "Use one Paperclip-managed sign-in for your selected 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" + ] + }, + "guidanceMd": "Choose Gmail, Drive, or Calendar and their access level before signing in. Only selected and granted actions are available.", + "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 services and read or write access before signing in." + }, + "grantKinds": [ + "user", + "organization" + ], + "ownershipModes": [ + "customer" + ], + "whenToUse": "Use one customer-owned Google OAuth client for your selected 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" + ], + "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..2a3eae7217d --- /dev/null +++ b/packages/shared/src/google-workspace-connectors.test.ts @@ -0,0 +1,87 @@ +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, + googleWorkspaceSelectedScopes, googleWorkspaceEffectiveScopes, GOOGLE_WORKSPACE_DEFAULT_SELECTION, +} from "./google-workspace-connectors.js"; + +const scope = (value: string) => `https://www.googleapis.com/auth/${value}`; + +describe("combined Google Workspace consent", () => { + it("defaults to Gmail, combined Drive, and Calendar write scopes without Chat or People", () => { + const selected = googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION); + expect(selected).toHaveLength(9); + expect(selected).toContain(scope("gmail.compose")); + expect(selected.some(value => /chat|contacts|directory|userinfo/.test(value))).toBe(false); + expect(googleWorkspaceSelectedScopes({})).toEqual([]); + expect(googleWorkspaceSelectedScopes({ drive: "read" })).toEqual([ + scope("documents.readonly"), scope("drive.readonly"), scope("presentations.readonly"), scope("spreadsheets.readonly"), + ]); + }); + + it("caps cumulative Google grants to the selected access on callback and refresh", () => { + const effective = googleWorkspaceEffectiveScopes([scope("documents.readonly")], [scope("documents"), scope("gmail.compose")]); + expect(effective).toEqual([scope("documents.readonly")]); + expect(isGoogleWorkspaceToolGranted("docs__update_doc", effective)).toBe(false); + expect(isGoogleWorkspaceToolGranted("docs__read_doc", effective)).toBe(true); + expect(googleWorkspaceEffectiveScopes([scope("documents")], [scope("documents.readonly")])).toEqual([scope("documents.readonly")]); + }); + it("requests the existing union with one profile and preserves legacy definitions", () => { + const expected = [...new Set(Object.entries(profiles).filter(([key]) => key !== "workspace.all" && key !== "gmail.archive").flatMap(([, value]) => value.scopes))]; + expect(profiles["workspace.all"].scopes).toEqual(expected); + expect(expected).toHaveLength(21); + expect(expected).not.toContain(scope("gmail.modify")); + 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.filter(scope => !/chat|contacts|directory|userinfo|\/calendar.readonly$/.test(scope))); + 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 601f10314c4..f2890ae7d6c 100644 --- a/packages/shared/src/google-workspace-connectors.ts +++ b/packages/shared/src/google-workspace-connectors.ts @@ -2,13 +2,55 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS = [ "gmail.read", "gmail.draft", "gmail.archive", "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]; +export type GoogleWorkspaceServiceSelection = Partial>; +export const GOOGLE_WORKSPACE_DEFAULT_SELECTION: GoogleWorkspaceServiceSelection = { gmail: "write", drive: "write", calendar: "write" }; +export const GOOGLE_WORKSPACE_SERVICE_GROUPS = [ + { id: "gmail", name: "Gmail", brandKey: "gmail", read: ["gmail.read"], write: ["gmail.draft"] }, + { id: "drive", name: "Drive", brandKey: "google-drive", description: "Docs, Sheets, and Slides", read: ["drive.read", "docs.read", "sheets.read", "slides.read"], write: ["drive.write", "docs.write", "sheets.write", "slides.write"] }, + { id: "calendar", name: "Calendar", brandKey: "google-calendar", read: ["calendar.read"], write: ["calendar.write"] }, +] as const; + +/** Derive resource permissions from closed service groups, never client-supplied scope strings. */ +export function googleWorkspaceSelectedScopes(selection: GoogleWorkspaceServiceSelection): string[] { + return [...new Set(GOOGLE_WORKSPACE_SERVICE_GROUPS.flatMap(group => { + const access = selection[group.id]; + return access ? group[access].flatMap(profile => GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes) : []; + }))].sort(); +} + +/** Keep provider consent inside this request, including a provider's read-only downgrade. */ +export function googleWorkspaceEffectiveScopes(requested: readonly string[], granted: readonly string[]): string[] { + const implies: Record = { + documents: ["documents.readonly"], spreadsheets: ["spreadsheets.readonly"], + presentations: ["presentations.readonly"], + "calendar.events": ["calendar.events.readonly", "calendar.events.freebusy"], + "calendar.readonly": ["calendar.calendarlist.readonly", "calendar.events.readonly", "calendar.events.freebusy"], + }; + const expand = (scopes: readonly string[]) => new Set(scopes.flatMap(scope => [scope, ...(implies[scope.replace("https://www.googleapis.com/auth/", "")] ?? []).map(value => `https://www.googleapis.com/auth/${value}`)])); + const allowed = expand(requested); + const effective = [...expand(granted)].filter(scope => allowed.has(scope)); + return effective.filter(scope => !effective.some(broader => broader !== scope && (implies[broader.replace("https://www.googleapis.com/auth/", "")] ?? []).some(value => scope === `https://www.googleapis.com/auth/${value}`))).sort(); +} + +export function googleWorkspaceSelectionForScopes(scopes: readonly string[]): GoogleWorkspaceServiceSelection { + const selection: GoogleWorkspaceServiceSelection = {}; + for (const group of GOOGLE_WORKSPACE_SERVICE_GROUPS) { + const writes = group.write.flatMap(profile => GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes); + const reads = group.read.flatMap(profile => GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes); + if (writes.every(scope => scopes.includes(scope))) selection[group.id] = "write"; + else if (reads.some(scope => scopes.includes(scope))) selection[group.id] = "read"; + } + return selection; +} + const auth = (scope: string) => `https://www.googleapis.com/auth/${scope}`; -export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly, { appSlug: string; serverUrl: string; scopes: readonly string[]; @@ -33,6 +75,99 @@ export const GOOGLE_WORKSPACE_CONNECTOR_PROFILES: Readonly id !== "gmail.archive").flatMap(([, profile]) => 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/packages/shared/src/validators/tool-access.test.ts b/packages/shared/src/validators/tool-access.test.ts index 3e3553cbcf6..b5838320115 100644 --- a/packages/shared/src/validators/tool-access.test.ts +++ b/packages/shared/src/validators/tool-access.test.ts @@ -5,12 +5,20 @@ import { createToolMcpGatewayTokenSchema, createToolConnectionSchema, startConnectionAuthorizationSchema, + startToolOAuthSchema, toolCredentialSecretRefSchema, toolRedactedValueSummarySchema, toolTransportConfigSchema, } from "./tool-access.js"; describe("tool access validators", () => { + it("accepts only nonempty reviewed Workspace service groups", () => { + expect(startToolOAuthSchema.parse({ workspaceServices: { gmail: "read", drive: "write" } }).workspaceServices).toEqual({ gmail: "read", drive: "write" }); + for (const workspaceServices of [{}, { chat: "read" }, { people: "read" }, { gmail: "admin" }]) { + expect(startToolOAuthSchema.safeParse({ workspaceServices }).success).toBe(false); + expect(connectToolAppSchema.safeParse({ galleryKey: "google-workspace", workspaceServices }).success).toBe(false); + } + }); it("treats a gateway token owner note as optional", () => { const parsed = createToolMcpGatewayTokenSchema.parse({ name: "cursor-client", diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts index 243ad9d1dcc..bafe22e80e1 100644 --- a/packages/shared/src/validators/tool-access.ts +++ b/packages/shared/src/validators/tool-access.ts @@ -411,7 +411,14 @@ function rejectUnsafeHeaderCredentials( } } +export const googleWorkspaceServiceSelectionSchema = z.object({ + gmail: z.enum(["read", "write"]).optional(), + drive: z.enum(["read", "write"]).optional(), + calendar: z.enum(["read", "write"]).optional(), +}).strict().refine(value => Object.keys(value).length > 0, "Select at least one Google service"); + export const connectToolAppSchema = z.object({ + workspaceServices: googleWorkspaceServiceSelectionSchema.optional(), agentInstructions: connectionAgentInstructionsSchema.nullable().optional(), galleryKey: z.string().trim().min(1).max(120).optional(), connectionMethodKey: z.string().trim().min(1).max(120).optional(), @@ -441,6 +448,9 @@ export const connectToolAppSchema = z.object({ /** Same-company agent that owns a dedicated provider identity. */ subjectAgentId: z.string().guid().optional(), }).superRefine((value, ctx) => { + if (value.workspaceServices && value.galleryKey !== "google-workspace") { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["workspaceServices"], message: "Service selection requires Google Workspace" }); + } if (value.configValues) rejectSensitiveConfigKeys(value.configValues, ctx, ["configValues"]); if (value.credentialValues) rejectUnsafeHeaderCredentials(value.credentialValues, ctx, ["credentialValues"]); if ((value.grantKind === "agent") !== Boolean(value.subjectAgentId)) { @@ -521,6 +531,7 @@ export const finalizeOAuthAccessSchema = z.object({ export type FinalizeOAuthAccess = z.infer; export const startToolOAuthSchema = z.object({ + workspaceServices: googleWorkspaceServiceSelectionSchema.optional(), asCurrentUser: z.boolean().optional(), asAgentId: z.string().uuid().optional(), interactionId: z.string().uuid().optional(), diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index f62069347c4..9d6cb4b60fe 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1807,10 +1807,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 f4cfcaf4cb4..7ed7118cc13 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -57,6 +57,8 @@ import { GITHUB_CONNECTOR_PROFILES, GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, + googleWorkspaceSelectedScopes, + GOOGLE_WORKSPACE_DEFAULT_SELECTION, getAvailableConnectionMethod, getConnectableAppDefinition, type GoogleWorkspaceConnectorProfileId, @@ -2527,6 +2529,143 @@ describeEmbeddedPostgres("tool access service", () => { expect(reconnected.connection.agentInstructions).toEqual(settings); }); + it.each(["user", "organization"] as const)("preserves the %s Workspace request when account-page reconnect omits a selection", async (kind) => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board" }; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: kind, + oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" }, + }, actor); + const requestedScopes = ["https://www.googleapis.com/auth/gmail.readonly"]; + await db.update(toolConnections).set({ status: "active" }).where(eq(toolConnections.id, connected.connectionId)); + await db.update(connectionGrants).set({ + providerTenant: { oauth: { requestedScopes, scopes: [...requestedScopes, "https://www.googleapis.com/auth/documents"] } }, + status: "needs_reauthorization", + }).where(and(eq(connectionGrants.connectionId, connected.connectionId), eq(connectionGrants.kind, kind))); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, + }); + const url = new URL(started.authorizationUrl); + expect(url.searchParams.get("scope")?.split(" ")).toEqual(requestedScopes); + const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!)); + expect(state.requestedScopes).toEqual(requestedScopes); + await db.update(connectionGrants).set({ providerTenant: {} }) + .where(eq(connectionGrants.connectionId, connected.connectionId)); + await expect(service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, + })).rejects.toMatchObject({ status: 400 }); + const selected = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, + workspaceServices: { gmail: "read" }, + }); + expect(new URL(selected.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(requestedScopes); + }); + + it("binds selected Workspace service scopes to OAuth state and rejects other providers", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const actor = { actorType: "user" as const, actorId: "board" }; + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", connectionMethodKey: "customer-workspace-oauth", grantKind: "user", + oauthClient: { clientId: "fixture-client", clientSecret: "fixture-secret" }, + }, actor); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: { drive: "read" }, + }); + const url = new URL(started.authorizationUrl); + const expected = ["documents.readonly", "drive.readonly", "presentations.readonly", "spreadsheets.readonly"].map(value => `https://www.googleapis.com/auth/${value}`); + expect(url.searchParams.get("scope")?.split(" ")).toEqual(expected); + const [state] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, url.searchParams.get("state")!)); + expect(state.requestedScopes).toEqual(expected); + await expect(service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, workspaceServices: {}, + })).rejects.toMatchObject({ status: 400 }); + }); + + 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(googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION)); + 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(["customer", "managed"] as const)( + "preserves an explicit empty agent selection through %s Workspace OAuth and reconnect", async (mode) => { + const company = await createCompany(db); + const userId = `workspace-no-agents-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all"); + const claim = connector.claim; + const scope = "https://www.googleapis.com/auth/documents.readonly"; + connector.claim = vi.fn(async (input) => ({ ...await claim(input), scopes: [scope] })); + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const input = { + galleryKey: "google-workspace", grantKind: "user" as const, + connectionMethodKey: mode === "customer" ? "customer-workspace-oauth" : "paperclip-workspace", + ...(mode === "customer" ? { oauthClient: { clientId: "workspace-client", clientSecret: "workspace-secret" } } : {}), + }; + 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 } }], + } }); + }); + const connected = await service.connectGalleryApp(company.id, input, actor); + await service.putConnectionInstalls(connected.connectionId, { installs: [] }, actor); + const redirectUri = `https://paperclip.example/api/tools/oauth/${mode === "managed" ? "cloud-connector/" : ""}callback`; + for (const reconnect of [false, true]) { + if (reconnect) await service.connectGalleryApp(company.id, { ...input, reconnectConnectionId: connected.connectionId }, actor); + 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: "workspace-no-agents", actor }) + : await service.completeOAuthCallback({ state, code: "workspace-no-agents", redirectUri, actor }); + expect(completed.connection.status).toBe("active"); + expect(completed.catalog.map((entry) => entry.toolName)).toEqual(["docs__read_doc"]); + expect(await service.listConnectionInstalls(connected.connectionId)).toEqual([]); + const [profile] = await db.select().from(toolProfiles).where(eq(toolProfiles.profileKey, `app:${connected.connectionId}`)); + expect(profile).toBeDefined(); + expect(await db.select().from(toolProfileBindings).where(eq(toolProfileBindings.profileId, profile.id))).toEqual([]); + } + }, + ); + 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); @@ -7994,6 +8133,244 @@ 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"); + }); + + describe("combined Workspace service isolation and refresh consent", () => { + async function connectWorkspace(mode: "managed" | "customer", permissions = ["documents.readonly", "gmail.readonly"]) { + const company = await createCompany(db); + const userId = `workspace-refresh-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const initialScopes = permissions.map((scope) => `https://www.googleapis.com/auth/${scope}`); + let authorizationScopes = initialScopes; + let refreshScopes: string[] | undefined = initialScopes; + const connector = fakeGoogleWorkspaceConnector(company.id, userId, "workspace.all"); + 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(); + const omitted = new Set(); + 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 : authorizationScopes)?.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" }] } }); + } + const names = host === "docsmcp.googleapis.com" ? ["read_doc", "update_doc"] : ["get_message"]; + return mcpHttpResponse({ jsonrpc: "2.0", id: body.id, result: { tools: omitted.has(host) ? [] : names.map((name) => ({ + name, annotations: { readOnlyHint: name !== "update_doc" }, + inputSchema: { type: "object", properties: {} }, + })) } }); + }); + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-workspace", grantKind: "organization", + 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, agent, service, connected, gateway, session, calls, unavailable, omitted, connector, tokenRefreshes, + setAuthorizationScopes: (scopes: string[]) => { authorizationScopes = scopes; }, + expireNextCall: (scopes: string[] | undefined) => { refreshScopes = scopes; unauthorizedNextCall = true; } }; + } + + it("preserves organization actions when a personal OAuth callback grants fewer permissions", async () => { + const fixture = await connectWorkspace("customer", ["documents", "gmail.readonly"]); + // This supported policy selects personal consent when present and the + // organization identity for callers without a personal grant. + await db.update(toolConnections).set({ credentialPolicy: "per_user_with_fallback" }) + .where(eq(toolConnections.id, fixture.connected.connectionId)); + const userId = `workspace-personal-${randomUUID()}`; + await grantBoardUser(db, fixture.company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; + fixture.setAuthorizationScopes(["https://www.googleapis.com/auth/documents.readonly"]); + const started = await fixture.service.startOAuth(fixture.company.id, fixture.connected.connectionId, { + redirectUri, actor, subjectUserId: userId, + }); + await fixture.service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, + code: "personal-docs-only", redirectUri, actor }); + const [gmailEntry] = await db.select().from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message"), + )); + expect(gmailEntry.status).toBe("active"); + const [docsWrite] = await db.select().from(toolCatalogEntries).where(and( + eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "docs__update_doc"), + )); + // Docs was probed, but its write action was filtered by personal consent, + // not removed by Google. The organization still retains that action. + expect(docsWrite.status).toBe("active"); + const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!; + expect(gmail).toBeTruthy(); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + await expect(fixture.gateway.executeTestCall({ companyId: fixture.company.id, connectionId: fixture.connected.connectionId, + agentId: fixture.agent.id, userId, toolName: "gmail__get_message", parameters: {} })).resolves.toMatchObject({ error: { reasonCode: "oauth_insufficient_scope" } }); + expect(fixture.calls).toEqual(["get_message"]); + // Once the other authority is revoked, a personal refresh can remove the + // unsupported shared entry; revoked grants never preserve capabilities. + await db.update(connectionGrants).set({ status: "revoked" }).where(and( + eq(connectionGrants.connectionId, fixture.connected.connectionId), eq(connectionGrants.kind, "organization"), + )); + await fixture.service.refreshCatalog(fixture.connected.connectionId, actor); + const [removed] = await db.select().from(toolCatalogEntries).where(eq(toolCatalogEntries.id, gmailEntry.id)); + expect(removed.status).toBe("disabled"); + }); + + it.each(["outage", "removed"] as const)("does not preserve a probed Gmail service's missing tools across grants (%s)", async (failure) => { + const fixture = await connectWorkspace("customer"); + const userId = `workspace-both-${randomUUID()}`; + await grantBoardUser(db, fixture.company.id, userId, [], "owner"); + const actor = { actorType: "user" as const, actorId: userId }; + const redirectUri = "https://paperclip.example/api/tools/oauth/callback"; + const started = await fixture.service.startOAuth(fixture.company.id, fixture.connected.connectionId, { redirectUri, actor, subjectUserId: userId }); + await fixture.service.completeOAuthCallback({ state: new URL(started.authorizationUrl).searchParams.get("state")!, code: "personal-both", redirectUri, actor }); + (failure === "outage" ? fixture.unavailable : fixture.omitted).add("gmailmcp.googleapis.com"); + const refreshed = await fixture.service.refreshCatalog(fixture.connected.connectionId, actor); + expect(refreshed.connection.healthStatus).toBe("ok"); + const tools = await fixture.gateway.listToolsForSession(fixture.session.token); + expect(tools.some((tool) => tool.upstreamToolName === "gmail__get_message")).toBe(false); + const doc = tools.find((tool) => tool.upstreamToolName === "docs__read_doc")!; + expect(doc).toBeTruthy(); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + expect(fixture.calls).toEqual(["read_doc"]); + const [gmail] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, fixture.connected.connectionId), eq(toolCatalogEntries.toolName, "gmail__get_message"))); + expect(gmail.status).toBe("disabled"); + }); + + it("keeps healthy service tools visible during a partial outage and restores recovered services", async () => { + const fixture = await connectWorkspace("managed"); + fixture.unavailable.add("gmailmcp.googleapis.com"); + 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("does not let a cached Gmail call failure hide healthy Docs actions", async () => { + const fixture = await connectWorkspace("customer"); + const gmail = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "gmail__get_message")!; + fixture.unavailable.add("gmailmcp.googleapis.com"); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: gmail.name, parameters: {} })).rejects.toMatchObject({ reasonCode: "mcp_remote_status" }); + expect((await fixture.service.getConnection(fixture.connected.connectionId)).healthStatus).toBe("ok"); + const doc = (await fixture.gateway.listToolsForSession(fixture.session.token)).find((tool) => tool.upstreamToolName === "docs__read_doc")!; + expect(doc).toBeTruthy(); + await expect(fixture.gateway.executeTool({ sessionToken: fixture.session.token, tool: doc.name, parameters: {} })).resolves.toMatchObject({ status: "completed" }); + expect(fixture.calls).toEqual(["read_doc"]); + }); + + it.each(["managed", "customer"] as const)("blocks explicit 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_refreshed_retry_required" }); + 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()}`; diff --git a/server/src/__tests__/tool-gateway-service.test.ts b/server/src/__tests__/tool-gateway-service.test.ts index 34d96ea95e6..5faedf14519 100644 --- a/server/src/__tests__/tool-gateway-service.test.ts +++ b/server/src/__tests__/tool-gateway-service.test.ts @@ -227,6 +227,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); }); @@ -1273,6 +1274,47 @@ 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("keeps direct tools/call for generic connections without the curated session preference", async () => { const { company, agent, run } = await createRunFixture(db); const { connection } = await createRemoteMcpToolFixture(db, company.id); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 51b4425e60e..48a590a5bc1 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -922,6 +922,7 @@ function connectorEnrollmentPrincipal(req: Request): string { const personalSubjectUserId = req.body.grantKind === "user" ? req.actor.userId ?? null : null; const dedicatedSubjectAgentId = req.body.grantKind === "agent" ? req.body.subjectAgentId ?? null : null; const start = await svc.startOAuth(companyId, result.connectionId, { + workspaceServices: req.body.workspaceServices, redirectUri: oauthRedirectUri(req), actor: getActorInfo(req), ...(personalSubjectUserId ? { subjectUserId: personalSubjectUserId } : {}), @@ -1008,6 +1009,7 @@ function connectorEnrollmentPrincipal(req: Request): string { await assertToolConnectionConfigureAccess(req, existing); } const result = await svc.startOAuth(existing.companyId, existing.id, { + workspaceServices: req.body.workspaceServices, redirectUri: oauthRedirectUri(req), actor: getActorInfo(req), returnTo: oauthBrowserOrigin(req) ?? undefined, diff --git a/server/src/services/gmail-tool-governance.test.ts b/server/src/services/gmail-tool-governance.test.ts index 9d0b0b27396..64060c072bb 100644 --- a/server/src/services/gmail-tool-governance.test.ts +++ b/server/src/services/gmail-tool-governance.test.ts @@ -23,6 +23,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 999236c6c54..80ce27343e3 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -55,6 +55,28 @@ 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); + 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); + }); it("accepts a managed test identity and rejects other environments at its broker", () => { const identity = config().config; const env = { @@ -211,6 +233,21 @@ describe("Paperclip Cloud connector", () => { expect(request).toHaveBeenCalledOnce(); }); + it("signs selected Workspace scopes without substituting the full profile", async () => { + const keys = config(); + const scopes = ["https://www.googleapis.com/auth/gmail.readonly"]; + const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + const claims = JSON.parse(Buffer.from(body.request.split(".")[1], "base64url").toString("utf8")); + expect(claims).toMatchObject({ prf: "workspace.all", scp: scopes }); + return Response.json({ confirmationUrl: "https://my.example.test/connections/confirm?session=workspace", expiresAt: "2099-01-01T00:00:00Z" }); + }); + const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch }); + await connector.startAuthorization({ subject, companyId, profile: "workspace.all", scopes, + returnUri: "https://paperclip.example.test/api/tools/oauth/cloud-connector/callback", returnState: "workspace-state" }); + expect(request).toHaveBeenCalledOnce(); + }); + it("starts a signed session with exact endpoint audience and scope contract", async () => { const keys = config(); const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { @@ -664,7 +701,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 3f330292dab..774dd1f3bc8 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"; @@ -274,7 +275,7 @@ export function createPaperclipCloudConnector(input: { async function call( operation: PaperclipCloudConnectorOperation, - claims: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string }, + claims: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; returnUri?: string; returnState?: string; claimId?: string; redemptionId?: string }, secret?: { field: "refreshToken" | "token" | "binding" | "acknowledgement"; value: string }, ): Promise { const endpoint = new URL(ENDPOINTS[operation], `${config.baseUrl}/`).toString(); @@ -298,7 +299,10 @@ export function createPaperclipCloudConnector(input: { const definition = connectorProfileDefinition(claims.profile); payload.prv = definition.provider; payload.prf = claims.profile; - payload.scp = [...definition.scopes]; + if (claims.scopes && (claims.profile !== "workspace.all" || !isGoogleWorkspaceScopeGrant(claims.scopes))) { + throw new PaperclipCloudConnectorError("Invalid requested service permissions", "CONNECTOR_CONFIG_INVALID"); + } + payload.scp = [...(claims.scopes ?? definition.scopes)]; } if (secret) payload.sh = await sha256Base64Url(secret.value); const body = { @@ -338,6 +342,7 @@ export function createPaperclipCloudConnector(input: { subject: string, companyId: string, profile: PaperclipCloudConnectorProfileId, + requestedScopes?: string[], ): SealedConnectorCredentials { const definition = connectorProfileDefinition(profile); const envelope = parseEnvelope(response.sealed, purpose, definition.provider, profile); @@ -348,7 +353,7 @@ export function createPaperclipCloudConnector(input: { config.environment, definition.provider, profile, - definition.scopes, + requestedScopes ?? definition.scopes, ); if ( credentials.instanceId !== config.instanceId @@ -362,7 +367,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; @@ -423,7 +428,7 @@ export function createPaperclipCloudConnector(input: { || !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null; return manager.appId; }, - async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) { + async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; returnUri: string; returnState: string }) { const profile = values.profile ?? "gmail.draft"; const response = await call("session", { ...values, profile }); if (typeof response.confirmationUrl !== "string" || typeof response.expiresAt !== "string") { @@ -472,9 +477,9 @@ export function createPaperclipCloudConnector(input: { ...(handoff ? { handoff } : {}), }; }, - async claim(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; claimId: string; redemptionId: string }) { + async claim(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; scopes?: string[]; claimId: string; redemptionId: string }) { const profile = values.profile ?? "gmail.draft"; - return openCredentials(await call("claim", { ...values, profile }), sealPurpose("initial", profile), values.subject, values.companyId, profile); + return openCredentials(await call("claim", { ...values, profile }), sealPurpose("initial", profile), values.subject, values.companyId, profile, values.scopes); }, async refresh(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; refreshToken: string }) { const profile = values.profile ?? "gmail.draft"; @@ -761,7 +766,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 4643c43d832..ea8dd9d8751 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -13,6 +13,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 { googleWorkspaceEffectiveScopes, GOOGLE_WORKSPACE_DEFAULT_SELECTION, googleWorkspaceSelectedScopes, type GoogleWorkspaceServiceSelection, 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 { mcpDiscoveryHttpFailure, retainMcpConnectionFailure, withMcpConnectionFailure } from "./mcp-connection-failure.js"; import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; @@ -2384,6 +2385,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"; @@ -2541,6 +2550,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 = @@ -2931,7 +2941,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") { @@ -7166,13 +7176,47 @@ export function toolAccessService( connection: typeof toolConnections.$inferSelect, credentialHeaders?: Record, actor?: ActorInfo, + workspaceEndpoint?: string, + unavailableServices: 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.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(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, // so a second DNS resolution here would reopen the rebinding window that // PAP-17098 closed for the OAuth endpoints. @@ -7512,6 +7556,7 @@ export function toolAccessService( connection: typeof toolConnections.$inferSelect, credentialHeaders?: Record, actor?: ActorInfo, + unavailableServices: string[] = [], ): Promise { assertSupportedConnection(connection); if (connection.connectionPurpose === "ai") throw unprocessable("AI connections provide runtime authentication, not tool actions"); @@ -7530,7 +7575,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(); } @@ -7634,6 +7679,7 @@ export function toolAccessService( ): Promise { 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); @@ -7695,7 +7741,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); @@ -7706,7 +7752,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." : isDaytonaConnection(connection) @@ -7779,12 +7827,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 ( @@ -7832,10 +7882,30 @@ export function toolAccessService( existingRows.map((entry) => [entry.toolName, entry]), ); const nativeDaytona = isDaytonaConnection(connection); - if (nativeDaytona || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { + if (connection.config.sourceTemplateKey === "google-workspace" + || nativeDaytona || isRemoteMcpConnectorMethod(connection.config.sourceTemplateKey, connection.config.connectionMethodKey)) { const discoveredNames = new Set(descriptors.map((descriptor) => descriptor.name)); + // The catalog is shared, but discovery uses only the caller's grant. A + // narrower personal consent must not remove actions another active grant + // still supports. Never combine scopes from different grants into authority. + let otherWorkspaceGrants: string[][] = []; + let selectedWorkspaceScopes: string[] = []; + if (connection.config.sourceTemplateKey === "google-workspace") { + const selected = await vaultGrantForConnection(connection, actor); + selectedWorkspaceScopes = googleWorkspaceGrantedScopes(selected); + const grants = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.status, "active"), + )); + otherWorkspaceGrants = grants.filter((grant) => grant.id !== selected?.id).map(googleWorkspaceGrantedScopes); + } + // Preserve only consent-filtered actions, not failed-service catalogs or + // tools a successful, sufficiently authorized discovery actually removed. const removedStatus = nativeDaytona ? "removed" : "disabled"; - const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== removedStatus).map((entry) => entry.id); + const removedIds = existingRows.filter((entry) => !discoveredNames.has(entry.toolName) && entry.status !== removedStatus + && !(otherWorkspaceGrants.some((scopes) => isGoogleWorkspaceToolGranted(entry.toolName, scopes)) + && !isGoogleWorkspaceToolGranted(entry.toolName, selectedWorkspaceScopes) + && !unavailableServices.includes(googleWorkspaceToolTarget(entry.toolName)?.service ?? ""))).map((entry) => entry.id); if (removedIds.length) await publish(tx => tx.update(toolCatalogEntries) .set({ status: removedStatus, quarantineReason: nativeDaytona ? "provider_tool_removed" : "mcp_tool_removed", updatedAt: refreshedAt }) .where(and(eq(toolCatalogEntries.companyId, connection.companyId), eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, removedIds)))); @@ -7971,7 +8041,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, @@ -11516,6 +11588,13 @@ export function toolAccessService( profile: profile.id, refreshToken: refreshSecret.value, }); + if (profile.id === "workspace.all") { + const requested = normalizeOauthScopes(oauthGrantConfig(grant).requestedScopes ?? oauthGrantConfig(grant).scopes); + credentials.scopes = googleWorkspaceEffectiveScopes(requested, credentials.scopes); + } + 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 && @@ -11811,6 +11890,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({ @@ -11873,6 +11958,9 @@ export function toolAccessService( unrequestedScopes: grantOauth.unrequestedScopes, }, }); + if (connection.config.sourceTemplateKey === "google-workspace") { + refreshed.scopes = googleWorkspaceEffectiveScopes(normalizeOauthScopes(grantOauth.requestedScopes ?? grantOauth.scopes), refreshed.scopes); + } const providerTenant = { ...(grant.providerTenant ?? {}), oauth: { @@ -14624,6 +14712,7 @@ export function toolAccessService( subjectUserId?: string; subjectAgentId?: string; scopes?: string[]; + workspaceServices?: GoogleWorkspaceServiceSelection; returnTo?: string; issueId?: string; interactionId?: string; @@ -14645,7 +14734,36 @@ export function toolAccessService( const galleryMethod = galleryEntry ? connectionMethodForConnection(galleryEntry, connection) : null; - const requestedScopes = (() => { + const requestedScopes = await (async () => { + if (input.workspaceServices && sourceTemplateKey !== "google-workspace") throw badRequest("Service selection requires Google Workspace"); + if (sourceTemplateKey === "google-workspace") { + if (input.workspaceServices && input.scopes) throw badRequest("Choose services or scopes, not both"); + const allowed = normalizeOauthScopes(galleryMethod?.defaults?.scopesHint); + let scopes = input.scopes ?? (input.workspaceServices ? googleWorkspaceSelectedScopes(input.workspaceServices) : undefined); + if (!scopes && connection.status !== "draft") { + // Account-page reconnect has no new service choice. Never replace its + // subject's saved request with the new-account write defaults. + const binding = actorBinding(input.actor); + const subjectUserId = input.subjectUserId ?? (binding.actorType === "user" ? binding.actorId : null); + const kind = connection.credentialPolicy === "per_user" ? "user" + : connection.credentialPolicy === "per_agent" ? "agent" : "organization"; + const [grant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.companyId, companyId), + eq(connectionGrants.connectionId, connection.id), + eq(connectionGrants.kind, kind), + kind === "user" ? eq(connectionGrants.subjectUserId, subjectUserId ?? "") + : kind === "agent" ? eq(connectionGrants.subjectAgentId, input.subjectAgentId ?? "") + : eq(connectionGrants.isDefault, true), + )).limit(1); + const saved = grant?.providerTenant?.oauth; + scopes = normalizeOauthScopes(saved?.requestedScopes ?? saved?.scopes) + .filter(scope => allowed.includes(scope)); + if (scopes.length === 0) throw badRequest("Choose your Google Workspace services before reconnecting"); + } + scopes ??= googleWorkspaceSelectedScopes(GOOGLE_WORKSPACE_DEFAULT_SELECTION); + if (!isGoogleWorkspaceScopeGrant(scopes) || scopes.some(scope => !allowed.includes(scope))) throw badRequest("Select at least one supported Google service"); + return scopes; + } if (!galleryMethod) return input.scopes ?? null; const allowed = normalizeOauthScopes(galleryMethod.defaults?.scopesHint); if (!input.scopes) return allowed; @@ -14866,6 +14984,7 @@ export function toolAccessService( : subjectUserId!, companyId, profile: managedProfile.id, + ...(managedProfile.id === "workspace.all" ? { scopes: requestedScopes! } : {}), returnUri: returnUri.toString(), returnState: state, }); @@ -14887,7 +15006,7 @@ export function toolAccessService( createdBySessionId: binding.sessionId, subjectUserId, subjectAgentId: authorizationSubjectAgentId, - requestedScopes: [...managedProfile.scopes], + requestedScopes: managedProfile.id === "workspace.all" ? requestedScopes! : [...managedProfile.scopes], returnTo: input.returnTo, issueId: intentLink?.issueId ?? input.issueId, interactionId: intentLink?.id, @@ -15005,6 +15124,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) @@ -15385,9 +15506,11 @@ export function toolAccessService( : suggestedAgentIds.length > 0 ? { agentIds: suggestedAgentIds } : "all_agents"; - // An explicitly saved empty Access selection means no agents. A missing - // selection must still use managed OAuth's subject/recommended defaults. - const remoteMcpAccess = isRemoteMcpConnectorMethod( + // Workspace setup saves the operator's install selection before OAuth too. + // An empty selection means no agents, not permission to apply all-agent + // defaults after the user returns from Google. + const preserveEmptyInstalls = input.connection.config.sourceTemplateKey === "google-workspace" + || isRemoteMcpConnectorMethod( input.connection.config.sourceTemplateKey, input.connection.config.connectionMethodKey, ) || (input.connection.transport === "mcp_remote" @@ -15395,7 +15518,7 @@ export function toolAccessService( const access: FinishToolApp["access"] = deferTaskAccess ? { agentIds: [] } : installs.length === 0 - ? remoteMcpAccess ? { agentIds: [] } : normalizedSuggestedAccess + ? preserveEmptyInstalls ? { agentIds: [] } : normalizedSuggestedAccess : companyInstall ? "all_agents" : { agentIds }; @@ -15430,7 +15553,7 @@ export function toolAccessService( }, input.actor, ); - if (!deferTaskAccess && !remoteMcpAccess && installs.length === 0) { + if (!deferTaskAccess && !preserveEmptyInstalls && installs.length === 0) { const installTargets = access === "all_agents" ? [ @@ -15553,7 +15676,13 @@ export function toolAccessService( profile: profile.id, claimId: input.claimId, redemptionId: input.state, + ...(profile.id === "workspace.all" && stateRow.requestedScopes ? { scopes: stateRow.requestedScopes } : {}), }); + if (profile.id === "workspace.all") { + const requested = normalizeOauthScopes(stateRow.requestedScopes); + credentials.scopes = googleWorkspaceEffectiveScopes(requested, credentials.scopes); + if (!isGoogleWorkspaceScopeGrant(credentials.scopes)) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" }); + } const refreshToken = credentials.refreshToken; if ((profile.provider === "google" || profile.provider === "asana") && !refreshToken) { throw unprocessable( @@ -15731,6 +15860,7 @@ export function toolAccessService( strategy: "paperclip_cloud_connector", accessTokenExpiresAt: credentials.accessTokenExpiresAt, scopes: credentials.scopes, + requestedScopes: stateRow.requestedScopes ?? undefined, tokenType: credentials.tokenType, ...(credentials.refreshTokenExpiresAt ? { refreshTokenExpiresAt: credentials.refreshTokenExpiresAt } @@ -16257,6 +16387,13 @@ 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" }); + } + if (sourceTemplateKey === "google-workspace") { + token.scope = googleWorkspaceEffectiveScopes(authorizedScopes, normalizeOauthScopes(token.scope)).join(" "); + if (!token.scope) throw unprocessable("Google did not grant any selected service permissions", { 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 95de0f8c244..c08b5f72be9 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -2,6 +2,7 @@ import { notifyDeliveryWork, DELIVERY_QUEUES } from "./delivery-work-notificatio import { measureToolDiscovery, measureRunPhase } from "./execution-preparation-timing.js"; import { executeDaytonaTool, isDaytonaConnection, isCurrentDaytonaCatalogEntry } from "./daytona.js"; 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"; @@ -2974,7 +2975,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) => @@ -3045,8 +3046,21 @@ export function createToolGatewayService( /** Shared connection descriptors and task restrictions; no provider calls. */ async function connectionToolsForContext(session: ToolGatewaySession): Promise { const guestBotConnection = await measureRunPhase("connectors.discovery.github", () => githubGuestBotConnectionForSession(db, session)); - const connectedTools = (await measureRunPhase("connectors.discovery.connections", () => connectedMcpToolsForCompany(session.companyId))) + let connectedTools = (await measureRunPhase("connectors.discovery.connections", () => 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 measureRunPhase("connectors.discovery.github", () => githubBotToolsForSession(db, session)), ...await measureRunPhase("connectors.discovery.slack", () => slackToolsForSession(db, session)), @@ -6007,7 +6021,19 @@ 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; + // One service's call failure is not evidence that every Google endpoint is + // unhealthy. The invocation still records/returns its error; catalog refresh + // handles service availability, and grant checks remain authoritative. + const markCallHealth = (status: Parameters[1], message: string) => + workspaceTarget && status !== "ok" ? Promise.resolve() : markRemoteConnectionHealth(connection, status, message); + 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. @@ -6032,7 +6058,7 @@ export function createToolGatewayService( endpoint: auditSafeEndpoint(endpoint), mcpMethod: "tools/call", requestId, - upstreamToolName: entry.toolName, + upstreamToolName, dispatched: true, }, }; @@ -6043,8 +6069,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(), @@ -6053,6 +6090,7 @@ export function createToolGatewayService( // letting the tighter default cut a legitimately slow tool short. responseTimeoutMs: ms, }); + }; const requireExplicitRetryAfterOAuthRefresh = async (refreshedResponse: Response): Promise => { // The provider rejected this dispatch, but automatic replay is unsafe: // a server can apply a request before returning 401. A caller retry @@ -6136,7 +6174,7 @@ export function createToolGatewayService( id: requestId, method: "tools/call", params: { - name: entry.toolName, + name: upstreamToolName, arguments: parameters, }, }), @@ -6144,7 +6182,7 @@ export function createToolGatewayService( let response = await dispatchRemote(endpoint, requestInit); if (isInsufficientConnectionScope(response)) { await response.body?.cancel().catch(() => undefined); - await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); + await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id }); } const oauth = asRecord(asRecord(connection.config)?.oauth); @@ -6266,7 +6304,7 @@ export function createToolGatewayService( response.headers.get("traceparent"), }; if (isInsufficientConnectionScope(response, body)) { - await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); + await markCallHealth("degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE); throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id, catalogEntryId: entry.id, }); @@ -6275,7 +6313,7 @@ export function createToolGatewayService( // Session expiration is recoverable on an explicit retry. Marking the // connection unhealthy here would hide every tool and prevent it. if (!sessionExpired) { - await markRemoteConnectionHealth(connection, "error", "Remote MCP server returned an HTTP error."); + await markCallHealth("error", "Remote MCP server returned an HTTP error."); } throw new ToolGatewayHttpError( 502, @@ -6323,8 +6361,7 @@ export function createToolGatewayService( } if (payloadRecord.error !== undefined) { const errorRecord = asRecord(payloadRecord.error); - await markRemoteConnectionHealth( - connection, + await markCallHealth( "error", "Remote MCP server returned a JSON-RPC error.", ); @@ -6365,8 +6402,7 @@ export function createToolGatewayService( false, sourceTemplateKey, ); - await markRemoteConnectionHealth( - connection, + await markCallHealth( "ok", "Remote MCP server responded to tools/call.", ); @@ -6401,8 +6437,7 @@ export function createToolGatewayService( ); } if (error instanceof Error && error.name === "AbortError") { - await markRemoteConnectionHealth( - connection, + await markCallHealth( "error", "Remote MCP tool call timed out.", ); @@ -6417,8 +6452,7 @@ export function createToolGatewayService( }, ); } - await markRemoteConnectionHealth( - connection, + await markCallHealth( "error", "Remote MCP tool call failed.", ); diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index 597371920bc..b5ee462fd90 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -229,6 +229,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/api/tools.ts b/ui/src/api/tools.ts index 1702478e54d..f8a726d4583 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -309,6 +309,7 @@ export const toolsApi = { asCurrentUser?: boolean; asAgentId?: string; interactionId?: string; + workspaceServices?: import("@paperclipai/shared").GoogleWorkspaceServiceSelection; } = {}, ) => api.post(`/tools/oauth/${connectionId}/start`, input), diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index e7622eec999..b45f73007c0 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -1,4 +1,6 @@ import { StepHeader } from "./ConnectionSetupHeader"; +import { GoogleWorkspaceServicesDialog } from "./GoogleWorkspaceServices"; +import type { GoogleWorkspaceServiceSelection } from "@paperclipai/shared"; export { StepHeader } from "./ConnectionSetupHeader"; import { ConnectionInstructionsEditor } from "./ConnectionInstructions"; import { connectionInstructionsConfig, defaultConnectionAgentInstructions, type ConnectionAgentInstructions } from "@paperclipai/shared"; @@ -624,6 +626,10 @@ function StandardConnectionSetupFlow({ onCancel, renderCredentialStep, }: ConnectionSetupFlowProps = {}) { + const workspaceSelectionRef = useRef(null); + const [workspaceConsent, setWorkspaceConsent] = useState<(() => void) | null>(null); + const [workspaceConnection, setWorkspaceConnection] = useState(); + const [workspaceDraft, setWorkspaceDraft] = useState(); const queryClient = useQueryClient(); const routeNavigate = useNavigate(); const navigate = useCallback((to: string, options?: { replace?: boolean }) => { @@ -1227,6 +1233,12 @@ function StandardConnectionSetupFlow({ }) : false; const identityConnection = resumeConnection ?? reconnectConnection; + useEffect(() => { + workspaceSelectionRef.current = null; + setWorkspaceConsent(null); + setWorkspaceConnection(undefined); + setWorkspaceDraft(undefined); + }, [selectedCompanyId, requestedAppKey, identityConnection?.id]); const reconnectGrantKind: ConnectionGrantKind | null = identityConnection ? identityConnection.credentialPolicy === "per_user" ? "user" @@ -1302,6 +1314,7 @@ function StandardConnectionSetupFlow({ await toolsApi.updateConnection(connection.id, { agentInstructions: instructionValue }); } return toolsApi.startOAuth(connection.id, { + ...(connection.config?.sourceTemplateKey === "google-workspace" && workspaceSelectionRef.current ? { workspaceServices: workspaceSelectionRef.current } : {}), asCurrentUser: connection.credentialPolicy === "per_user", ...(connection.credentialPolicy === "per_agent" ? { asAgentId: configuredAgentIdentity(connection) ?? [...installAgentIds][0] } @@ -1315,6 +1328,7 @@ function StandardConnectionSetupFlow({ ? (error.body as { details?: { code?: unknown } }).details : null; setOAuthPhase("error"); + workspaceSelectionRef.current = null; onPhaseChange?.("needs_retry"); setOAuthError( details?.code === "invalid_grant" @@ -1327,6 +1341,16 @@ function StandardConnectionSetupFlow({ }); const mutateOAuthStart = oauthStartMutation.mutate; const startOAuth = useCallback((connection: ToolConnection) => { + if (connection.config?.sourceTemplateKey === "google-workspace" && !workspaceSelectionRef.current) { + setWorkspaceConnection(connection); + setWorkspaceConsent(() => () => { + onPhaseChange?.("authorizing"); + reserveOAuthPopup(); + if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id); + mutateOAuthStart(connection); + }); + return; + } onPhaseChange?.("authorizing"); reserveOAuthPopup(); if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id); @@ -1369,6 +1393,7 @@ function StandardConnectionSetupFlow({ requestedGrantKind, ); result = await toolsApi.connectApp(selectedCompanyId!, { + ...(connectEntry.slug === "google-workspace" && workspaceSelectionRef.current ? { workspaceServices: workspaceSelectionRef.current } : {}), galleryKey: connectEntry.slug, ...(instructionValue ? { agentInstructions: instructionValue } : {}), ...(connectionMethodKey ? { connectionMethodKey } : {}), @@ -1489,6 +1514,7 @@ function StandardConnectionSetupFlow({ const details = error instanceof ApiError && error.body && typeof error.body === "object" ? (error.body as { details?: { code?: unknown } }).details : null; + if (entry?.slug === "google-workspace") workspaceSelectionRef.current = null; if (automaticOAuthEntry) { setOAuthPhase("error"); onPhaseChange?.("needs_retry"); @@ -1530,11 +1556,19 @@ function StandardConnectionSetupFlow({ const method = connectEntry ? availableToolConnectionMethod(connectEntry, connectionMethodKey || null) : null; + if (connectEntry?.slug === "google-workspace" && !workspaceSelectionRef.current) { + setWorkspaceConnection(identityConnection ?? undefined); + setWorkspaceConsent(() => () => { + reserveOAuthPopup(); + mutateConnect(entryOverride); + }); + return; + } if (method?.auth === "oauth") { reserveOAuthPopup(); } mutateConnect(entryOverride); - }, [connectionMethodKey, entry, mutateConnect, reserveOAuthPopup]); + }, [connectionMethodKey, entry, identityConnection, mutateConnect, reserveOAuthPopup]); useEffect(() => { if (!requestedAppKey || galleryQuery.isLoading || !galleryQuery.data) return; @@ -2172,6 +2206,19 @@ function StandardConnectionSetupFlow({ ) ); + if (workspaceConsent) return { if (selection) setWorkspaceDraft(selection); setWorkspaceConsent(null); setOAuthPhase("entry"); onPhaseChange?.("requested"); }} + onContinue={selection => { + workspaceSelectionRef.current = selection; + setWorkspaceDraft(selection); + const proceed = workspaceConsent; + setWorkspaceConsent(null); + proceed(); + }} + />; + if (showCuratedOAuthState && automaticOAuthEntry) { return ( vi.fn()); +vi.mock("@/api/tools", () => ({ toolsApi: { listConnectionGrants: listGrants } })); +vi.mock("@/pages/apps/AppLogo", () => ({ AppLogo: () => null })); + +describe("Workspace service consent", () => { + let root: Root; + let container: HTMLDivElement; + let client: QueryClient; + const onContinue = vi.fn(); + const onCancel = vi.fn(); + const existing = { id: "workspace-account", status: "active", credentialPolicy: "per_user", config: {} } as ToolConnection; + const flush = async () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); }; + const click = async (name: string) => { + const button = [...document.querySelectorAll("button")].find(item => item.textContent === name)!; + await act(async () => { button.click(); }); + await flush(); + }; + async function render(connection?: ToolConnection) { + await act(async () => { root.render( + + ); }); + await flush(); + await flush(); + } + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + }); + afterEach(async () => { + await act(async () => { root.unmount(); }); + client.clear(); + container.remove(); + }); + + it("defaults all three groups to write and submits only their selections", async () => { + await render(); + expect([...document.querySelectorAll('[role="switch"]')].map(item => item.getAttribute("aria-checked"))).toEqual(["true", "true", "true"]); + expect([...document.querySelectorAll("select")].map(item => item.value)).toEqual(["write", "write", "write"]); + expect(listGrants).not.toHaveBeenCalled(); + await click("Continue"); + expect(onContinue).toHaveBeenCalledWith({ gmail: "write", drive: "write", calendar: "write" }); + }); + + it("restores the current user's saved request, not an organization or another user's broader grant", async () => { + listGrants.mockResolvedValue({ currentUserId: "me", grants: [ + { kind: "organization", providerTenant: { oauth: { requestedScopes: ["https://www.googleapis.com/auth/documents"] } } }, + { kind: "user", subjectUserId: "other", providerTenant: { oauth: { requestedScopes: ["https://www.googleapis.com/auth/calendar.events"] } } }, + { kind: "user", subjectUserId: "me", providerTenant: { oauth: { + requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"], + scopes: ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/documents"], + } } }, + ] }); + await render(existing); + expect([...document.querySelectorAll('[role="switch"]')].map(item => item.getAttribute("aria-checked"))).toEqual(["true", "false", "false"]); + expect(document.querySelector("select")?.value).toBe("read"); + await click("Continue"); + expect(onContinue).toHaveBeenCalledWith({ gmail: "read" }); + }); + + it("blocks on grant-load failure and retries without broadening the saved request", async () => { + listGrants.mockRejectedValueOnce(new Error("unavailable")); + await render(existing); + expect(document.body.textContent).toContain("Could not load your saved service access"); + expect(document.querySelector('[role="switch"]')).toBeNull(); + expect(onContinue).not.toHaveBeenCalled(); + listGrants.mockResolvedValue({ currentUserId: "me", grants: [] }); + await click("Try again"); + await flush(); + const continueButton = [...document.querySelectorAll("button")].find(item => item.textContent === "Continue")!; + expect(continueButton.disabled).toBe(true); + await click("Cancel"); + expect(onCancel).toHaveBeenCalledWith({}); + expect(onContinue).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/features/connections/GoogleWorkspaceServices.tsx b/ui/src/features/connections/GoogleWorkspaceServices.tsx new file mode 100644 index 00000000000..70d1eb16931 --- /dev/null +++ b/ui/src/features/connections/GoogleWorkspaceServices.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, GOOGLE_WORKSPACE_SERVICE_GROUPS, googleWorkspaceSelectionForScopes, type GoogleWorkspaceServiceSelection, type ToolConnection } from "@paperclipai/shared"; +import { toolsApi } from "@/api/tools"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { NativeSelect } from "@/components/ui/select"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { AppLogo } from "@/pages/apps/AppLogo"; + +export function GoogleWorkspaceServiceFields({ value, onChange }: { + value: GoogleWorkspaceServiceSelection; + onChange: (value: GoogleWorkspaceServiceSelection) => void; +}) { + return
+ {GOOGLE_WORKSPACE_SERVICE_GROUPS.map(group =>
+ +
+ + {"description" in group &&

{group.description}

} +
+ onChange({ ...value, [group.id]: event.target.value as "read" | "write" })}> + + + { + const next = { ...value }; + if (checked) next[group.id] = "write"; + else delete next[group.id]; + onChange(next); + }} /> +
)} +
; +} + +type DialogProps = { + onContinue: (value: GoogleWorkspaceServiceSelection) => void; + onCancel: (selection?: GoogleWorkspaceServiceSelection) => void; + initial?: GoogleWorkspaceServiceSelection; + connection?: ToolConnection; +}; + +export function GoogleWorkspaceServicesDialog(props: DialogProps) { + const existing = props.connection && props.connection.status !== "draft" ? props.connection : null; + const grants = useQuery({ queryKey: ["workspace-service-consent", existing?.id], queryFn: () => toolsApi.listConnectionGrants(existing!.id), enabled: Boolean(existing) }); + if (existing && !grants.data) return { if (!open) props.onCancel(); }}> + Connect Google Workspace{grants.isError ? "Could not load your saved service access. Your connection has not changed." : "Loading your saved service access…"} +
{grants.isError && }
+
; + const grant = grants.data?.grants.find(grant => existing?.credentialPolicy === "per_user" + ? grant.kind === "user" && grant.subjectUserId === grants.data?.currentUserId + : existing?.credentialPolicy === "per_agent" + ? grant.kind === "agent" && grant.subjectAgentId === (existing.config?.oauth as { connectorSubjectAgentId?: string } | undefined)?.connectorSubjectAgentId + : grant.kind === "organization"); + const savedScopes = grant?.providerTenant?.oauth?.requestedScopes ?? grant?.providerTenant?.oauth?.scopes ?? []; + return ; +} + +function ServicePicker({ onContinue, onCancel, initial = GOOGLE_WORKSPACE_DEFAULT_SELECTION }: DialogProps) { + const [selection, setSelection] = useState(initial); + const empty = Object.keys(selection).length === 0; + return { if (!open) onCancel(selection); }}> + + +
Connect Google Workspace
+ Choose the Google services to connect. +
+ + {empty &&

Select at least one service to continue.

} +
+ + +
+
+
; +} diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index 779dfe5c6ab..352d4148d88 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -1793,7 +1793,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}`; @@ -1814,7 +1814,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 () => { @@ -1851,8 +1851,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { buttonByText("Continue to sign in")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); + if (slug === "google-workspace") { + await act(async () => buttonByText("Continue")!.click()); + await flushReact(); + } expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ - galleryKey: slug, connectionMethodKey: "paperclip-read", grantKind: "user", + galleryKey: slug, connectionMethodKey: readMethod.key, grantKind: "user", })); }); @@ -1891,6 +1895,45 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).toContain("needs its own OAuth app"); }); + it("selects Workspace service groups before creating a single connection", 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).not.toHaveBeenCalled(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain("Docs, Sheets, and Slides"); + expect(dialog.querySelectorAll('[role="switch"]')).toHaveLength(3); + expect(dialog.textContent).not.toContain("Contacts"); + expect(dialog.textContent).not.toContain("compose permission"); + const gmail = dialog.querySelector('select[aria-label="Gmail access"]') as HTMLSelectElement; + await act(async () => { + gmail.value = "read"; + gmail.dispatchEvent(new Event("change", { bubbles: true })); + }); + await act(async () => { + Array.from(dialog.querySelectorAll("button")).find(button => button.textContent === "Continue")!.click(); + }); + await flushReact(); + expect(connectAppMock).toHaveBeenCalledTimes(1); + expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ + galleryKey: "google-workspace", connectionMethodKey: "paperclip-workspace", + workspaceServices: { gmail: "read", drive: "write", calendar: "write" }, + })); + }); + 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 3a1b30589aa..693fc8a9626 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -810,11 +810,12 @@ 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", ]; - it("temporarily hides all Google Workspace catalog rows without changing their definitions", async () => { + it("offers one Google Workspace connector while retaining the individual service definitions", async () => { const definitions = googleSlugs.map((slug) => getAppStoreDefinition(slug)!); listGalleryMock.mockResolvedValue({ apps: [...definitions, getAppStoreDefinition("notion")] }); const client = await renderBrowse(); @@ -822,17 +823,23 @@ describe("Connectors landing page", () => { for (const definition of definitions) { expect(definition.methods.length).toBeGreaterThan(0); expect(getAppStoreDefinition(definition.slug)).toBe(definition); - expect(container.querySelector(`[data-app-slug="${definition.slug}"]`)).toBeNull(); + if (definition.slug === "google-workspace") { + expect(container.querySelectorAll('[data-app-slug="google-workspace"]')).toHaveLength(1); + } else { + expect(container.querySelector(`[data-app-slug="${definition.slug}"]`)).toBeNull(); + } } expect(container.querySelector('[data-app-slug="notion"]')).not.toBeNull(); expect(client.getQueryData(queryKeys.apps.gallery("company-1"))).toEqual({ apps: [...definitions, getAppStoreDefinition("notion")], }); expect(archiveConnectionMock).not.toHaveBeenCalled(); + await clickButton("Connect", container.querySelector('[data-app-slug="google-workspace"]')!); + expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=google-workspace"); }); it.each(["active", "draft", "disabled"])( - "hides saved Google %s accounts without disabling or removing them", + "shows saved Google %s accounts without changing their access or status", async (status) => { const applications = googleSlugs.map((slug) => application({ id: `app-${slug}`, name: `Saved ${slug}`, applicationKey: `app-gallery:${slug}:one`, @@ -848,8 +855,8 @@ describe("Connectors landing page", () => { const client = await renderBrowse(); for (const slug of googleSlugs) { - expect(container.querySelector(`[data-app-slug="${slug}"]`)).toBeNull(); - expect(container.textContent).not.toContain(`Account for ${slug}`); + expect(container.querySelector(`[data-app-slug="${slug}"]`)).not.toBeNull(); + expect(container.textContent).toContain(`Account for ${slug}`); } expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections }); expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications }); @@ -858,7 +865,7 @@ describe("Connectors landing page", () => { ); it.each(["config", "transportConfig"])( - "hides a Google account identified by %s even when its gallery entry is absent", + "shows a Google account identified by %s even when its gallery entry is absent", async (sourceField) => { listGalleryMock.mockResolvedValue({ apps: [] }); listApplicationsMock.mockResolvedValue({ applications: [application({ @@ -870,14 +877,14 @@ describe("Connectors landing page", () => { }), connection()] }); await renderBrowse(); - expect(container.textContent).not.toContain("Saved Google account"); + expect(container.textContent).toContain("Saved Google account"); expect(container.textContent).toContain("Notion"); expect(archiveConnectionMock).not.toHaveBeenCalled(); }, ); it.each(["catalog", "custom"])( - "preserves non-Google accounts in a mixed-provider %s row", + "preserves both Google and non-Google accounts in a mixed-provider %s row", async (rowKind) => { const notion = getAppStoreDefinition("notion")!; listGalleryMock.mockResolvedValue({ apps: rowKind === "catalog" ? [notion] : [] }); @@ -885,7 +892,7 @@ describe("Connectors landing page", () => { ? { name: "My tools", applicationKey: null, metadata: null } : {})] }); const connections = [connection({ - id: "google-account", name: "Hidden Google account", + id: "google-account", name: "Saved Google account", config: { sourceTemplateKey: "google-docs" }, }), connection({ id: "notion-account", name: "Visible Notion account", @@ -894,8 +901,9 @@ describe("Connectors landing page", () => { listConnectionsMock.mockResolvedValue({ connections }); const client = await renderBrowse(); + await clickButton("2 connections"); expect(container.textContent).toContain("Visible Notion account"); - expect(container.textContent).not.toContain("Hidden Google account"); + expect(container.textContent).toContain("Saved Google account"); expect(container.textContent).toContain(rowKind === "catalog" ? "Notion" : "My tools"); expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections }); expect(archiveConnectionMock).not.toHaveBeenCalled(); @@ -903,7 +911,7 @@ describe("Connectors landing page", () => { ); it.each(["metadata", "applicationKey"])( - "keeps a non-Google custom connector identified by %s when only its Google accounts are hidden", + "keeps a non-Google custom connector identified by %s and its saved Google account", async (sourceField) => { listGalleryMock.mockResolvedValue({ apps: [] }); const savedApplication = application({ @@ -913,14 +921,14 @@ describe("Connectors landing page", () => { }); listApplicationsMock.mockResolvedValue({ applications: [savedApplication] }); const connections = [connection({ - name: "Hidden Google account", config: { sourceTemplateKey: "google-docs" }, + name: "Saved Google account", config: { sourceTemplateKey: "google-docs" }, })]; listConnectionsMock.mockResolvedValue({ connections }); const client = await renderBrowse(); expect(container.querySelector('[data-app-slug="custom-provider"]')).not.toBeNull(); expect(container.textContent).toContain("My custom connector"); - expect(container.textContent).not.toContain("Hidden Google account"); + expect(container.textContent).toContain("Saved Google account"); expect(client.getQueryData(queryKeys.tools.applications("company-1"))).toEqual({ applications: [savedApplication] }); expect(client.getQueryData(queryKeys.tools.connections("company-1"))).toEqual({ connections }); expect(archiveConnectionMock).not.toHaveBeenCalled(); diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 37385598800..eb1096b1e85 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -142,11 +142,12 @@ type ConnectionRemovalTarget = { poolRevision?: number; } & ({ kind: "chat"; provider: ChatProvider } | { kind?: undefined }); -// Temporary, page-only hold until Google OAuth verification is approved. -// Keep definitions, direct setup/management routes, and runtime access intact. -// Remove this filter after approval; reviewer instances stay on their pinned build. -const GOOGLE_CONNECTOR_SLUGS = new Set( - Object.values(GOOGLE_WORKSPACE_CONNECTOR_PROFILES).map((profile) => profile.appSlug), +// Offer the unified connector for new Google accounts. Existing per-service +// connections remain visible and keep their definitions and management routes. +const LEGACY_GOOGLE_CONNECTOR_SLUGS = new Set( + Object.values(GOOGLE_WORKSPACE_CONNECTOR_PROFILES) + .map((profile) => profile.appSlug) + .filter((slug) => slug !== "google-workspace"), ); function chatProviderForSlug(slug: string): ChatProvider | null { @@ -641,15 +642,7 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne const savedAppConnections = connectionsByApplicationId.get(application.id) ?? []; if (applicationSlug === "gateway" && savedAppConnections.length === 0) continue; - let appConnections = savedAppConnections.filter( - (connection) => !GOOGLE_CONNECTOR_SLUGS.has(appConnectionSourceSlug(connection) ?? ""), - ); - // Hide source-only Google rows, but keep independently identified connectors. - if ( - (!applicationSlug || applicationSlug === "link") && - savedAppConnections.length > 0 && - appConnections.length === 0 - ) continue; + let appConnections = savedAppConnections; // One legacy gateway application may contain several API formats. Group // each saved AI account by its actual routing, not the old application slug. const ungroupedCount = appConnections.length; @@ -770,7 +763,9 @@ export function Browse({ renderAccountDetails = (connection) => connection.conne })); return [...rowsBySlug.values(), ...customRows, ...aggregatorRows] - .filter((row) => !GOOGLE_CONNECTOR_SLUGS.has(row.slug) || row.upstreamApps?.some(snapshot => snapshot.accounts.length > 0)) + .filter((row) => !LEGACY_GOOGLE_CONNECTOR_SLUGS.has(row.slug) + || row.connections.length > 0 + || row.upstreamApps?.some(snapshot => snapshot.accounts.length > 0)) .map((row) => ({ ...row, connections: [...row.connections].sort( diff --git a/ui/storybook/prototypes/google-workspace/ServiceConsent.test.ts b/ui/storybook/prototypes/google-workspace/ServiceConsent.test.ts new file mode 100644 index 00000000000..0a9213805b0 --- /dev/null +++ b/ui/storybook/prototypes/google-workspace/ServiceConsent.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { defaultSelection, selectedScopes, services } from "./ServiceConsent"; + +const scope = (name: string) => `https://www.googleapis.com/auth/${name}`; +describe("Workspace service consent preview", () => { + it("requests nothing before selection", () => { + expect(selectedScopes({})).toEqual([]); + }); + it("keeps a Gmail-only reviewer request read-only", () => { + expect(selectedScopes({ gmail: "read" })).toEqual([scope("gmail.readonly")]); + }); + it("adds compose only for the explicit draft selection", () => { + expect(selectedScopes({ gmail: "write" })).toEqual([scope("gmail.compose"), scope("gmail.readonly")]); + }); + it("checks every supported service with write access by default", () => { + expect(Object.keys(defaultSelection)).toEqual(services.map(service => service.id)); + expect(Object.values(defaultSelection)).toEqual(Array(3).fill("write")); + const scopes = selectedScopes(defaultSelection); + expect(scopes.some(value => value.includes("/auth/chat."))).toBe(false); + expect(scopes).toContain(scope("gmail.compose")); + expect(scopes).not.toContain(scope("contacts.readonly")); + expect(scopes).not.toContain(scope("directory.readonly")); + }); + it("ignores removed service keys in old preview selections", () => { + expect(selectedScopes({ chat: "write", search: "read", people: "read", docs: "write", sheets: "write", slides: "write" })).toEqual([]); + }); + it("combines Drive, Docs, Sheets, and Slides into one deduplicated permission group", () => { + expect(selectedScopes({ drive: "write" })).toEqual([ + scope("documents"), scope("drive.file"), scope("drive.readonly"), scope("presentations"), scope("spreadsheets"), + ]); + expect(selectedScopes({ drive: "read" })).toEqual([ + scope("documents.readonly"), scope("drive.readonly"), scope("presentations.readonly"), scope("spreadsheets.readonly"), + ]); + }); +}); diff --git a/ui/storybook/prototypes/google-workspace/ServiceConsent.tsx b/ui/storybook/prototypes/google-workspace/ServiceConsent.tsx new file mode 100644 index 00000000000..fca3ebb03d1 --- /dev/null +++ b/ui/storybook/prototypes/google-workspace/ServiceConsent.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { Check, ChevronLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { AppLogo } from "@/pages/apps/AppLogo"; +import { GoogleWorkspaceServiceFields } from "@/features/connections/GoogleWorkspaceServices"; +import { GOOGLE_WORKSPACE_DEFAULT_SELECTION, GOOGLE_WORKSPACE_SERVICE_GROUPS, googleWorkspaceSelectedScopes } from "@paperclipai/shared"; + +const permissionDetails = { + gmail: "Google’s compose permission also permits sending email. Paperclip currently exposes draft creation, not sending.", + drive: "Read files across Drive; create files opened or created with this app, and edit documents, spreadsheets, and presentations.", + calendar: "Managing events includes creating, changing, deleting, and responding to events.", +}; +export const services = GOOGLE_WORKSPACE_SERVICE_GROUPS.map(group => ({ ...group, slug: group.brandKey, warning: permissionDetails[group.id] })); +export type Selection = Partial>; +export const defaultSelection: Selection = GOOGLE_WORKSPACE_DEFAULT_SELECTION; +export function selectedScopes(selection: Selection) { + return googleWorkspaceSelectedScopes(selection); +} + +export function ServiceConsent({ initial = defaultSelection, existing = false, error = false, start = "services" }: { + initial?: Selection; existing?: boolean; error?: boolean; start?: "catalog" | "services" | "review"; +}) { + const [selection, setSelection] = useState(initial); + const [open, setOpen] = useState(start !== "catalog"); + const [step, setStep] = useState<"services" | "review">(start === "review" ? "review" : "services"); + const [handoff, setHandoff] = useState(false); + const [failed, setFailed] = useState(false); + const scopes = selectedScopes(selection); + const previousScopes = selectedScopes(existing ? initial : {}); + const added = scopes.filter(scope => !previousScopes.includes(scope)); + const chosen = services.filter(service => selection[service.id]); + const warnings = chosen.filter(service => service.warning && selection[service.id] === "write"); + return
+
+

Apps

Paperclip
+

Connectors

+
+

Google Workspace

Gmail, Drive, and Calendar

+

One Google account. Choose which services Paperclip can access.

+ {existing &&

reviewer@example.com · Gmail

} + +
+
+ + + +
+ {existing ? "Manage Google Workspace services" : "Connect Google Workspace"} + {existing ? "reviewer@example.com" : "Choose the Google services to connect."} +
+
+ {step === "services" ? <>
+ + {!chosen.length &&

Select at least one service to continue.

} +
+ : <> +
+

{existing ? "Review your updated access" : "Google will ask for access to"}

+ {chosen.map(service =>
{service.name}{selection[service.id] === "write" ? "Read & write" : "Read only"}
)} +
Exact Google permissions ({scopes.length})
{warnings.map(service =>

{service.warning}

)}
    {scopes.map(scope =>
  • {scope}{existing && !previousScopes.includes(scope) && New}
  • )}
+ {existing &&

{added.length ? `${added.length} additional permissions need your consent. Your existing connection stays unchanged until Google confirms access.` : "No additional Google permissions requested. Turning a service off in Paperclip does not revoke permissions already granted to Google."}

} + {failed &&

Google sign-in could not start. Your selections are saved in this preview. Try again.

} + {handoff &&

{existing && !added.length ? "Service changes previewed" : "Permission request prepared"}

{existing && !added.length ? "Storybook preview only. No saved connection or Google permissions were changed." : "Storybook preview only. In the real flow, Google opens here to confirm this exact permission set. No account was connected."}

} +
+
+ } +
+
+
; +} diff --git a/ui/storybook/stories/connection-setup/google-workspace-services.stories.tsx b/ui/storybook/stories/connection-setup/google-workspace-services.stories.tsx new file mode 100644 index 00000000000..8a1f3e44221 --- /dev/null +++ b/ui/storybook/stories/connection-setup/google-workspace-services.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { GoogleWorkspaceServicesDialog } from "@/features/connections/GoogleWorkspaceServices"; +import { ServiceConsent } from "../../prototypes/google-workspace/ServiceConsent"; + +const meta = { + id: "apps-connections-google-workspace-service-consent-preview", + title: "Apps, channels, and extensions/Connection setup/Google Workspace service consent", + component: ServiceConsent, + parameters: { layout: "fullscreen", paperclip: { + capabilities: ["connection-setup/catalog", "app-permissions/account-maintenance"], + status: "current", implementation: "mixed", boundary: "fixtures", + notes: "Production service fields and picker with simulated journey callbacks. No provider authorization, persistence, or Google verification is established by these stories.", + }, docs: { description: { component: "The production service picker and fixture-backed journey previews. Google authorization is simulated without provider calls. Paperclip audiences and action policies remain separate." } } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const ProductionPicker: Story = { + render: () => , + parameters: { docs: { description: { story: "The production picker used by both Apps and task-hosted OAuth setup. Callbacks are mocked in Storybook; no OAuth grant is created." } } }, +}; +export const ChooseServices: Story = { args: {} }; +export const FromApps: Story = { args: { start: "catalog" } }; +export const GmailOnlyReviewer: Story = { args: { initial: { gmail: "read" }, start: "review" } }; +export const SelectedServices: Story = { args: { initial: { gmail: "read", drive: "read", calendar: "write" } } }; +export const DraftsPermissionDisclosure: Story = { args: { initial: { gmail: "write" }, start: "review" } }; +export const ExistingAccount: Story = { args: { initial: { gmail: "read" }, existing: true } }; +export const SignInRecovery: Story = { args: { initial: { gmail: "read" }, start: "review", error: true } }; +export const Mobile: Story = { globals: { viewport: { value: "mobile", isRotated: false } } }; +export const Light: Story = { globals: { theme: "light" } }; +export const AllServicesCheckedByDefault: Story = { + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const toggles = body.getAllByRole("switch"); + await expect(toggles).toHaveLength(3); + for (const toggle of toggles) await expect(toggle).toBeChecked(); + for (const dropdown of body.getAllByRole("combobox")) await expect(dropdown).toHaveValue("write"); + await expect(body.queryByText(/Google’s compose permission/)).not.toBeInTheDocument(); + await expect(body.queryByRole("switch", { name: /^Chat$/ })).not.toBeInTheDocument(); + await expect(body.queryByRole("switch", { name: /Contacts|directory/ })).not.toBeInTheDocument(); + await userEvent.click(body.getByRole("button", { name: /^Continue$/ })); + await userEvent.click(body.getByText(/^Exact Google permissions/)); + await expect(body.queryByText(/googleapis.com\/auth\/chat\./)).not.toBeInTheDocument(); + }, +}; +export const SelectionChangesPermissions: Story = { + args: { initial: {} }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await expect(body.getByRole("button", { name: /^Continue$/ })).toBeDisabled(); + await userEvent.click(body.getByRole("switch", { name: /^Gmail$/ })); + await userEvent.selectOptions(body.getByRole("combobox", { name: "Gmail access" }), "read"); + await userEvent.click(body.getByRole("button", { name: /^Continue$/ })); + await userEvent.click(body.getByText("Exact Google permissions (1)")); + await expect(body.getByText("https://www.googleapis.com/auth/gmail.readonly", { exact: true })).toBeVisible(); + await expect(body.queryByText("https://www.googleapis.com/auth/drive.readonly", { exact: true })).not.toBeInTheDocument(); + await userEvent.click(body.getByRole("button", { name: /^Back$/ })); + await userEvent.selectOptions(body.getByRole("combobox", { name: "Gmail access" }), "write"); + await userEvent.click(body.getByRole("button", { name: /^Continue$/ })); + await userEvent.click(body.getByText("Exact Google permissions (2)")); + await expect(body.getByText("https://www.googleapis.com/auth/gmail.compose", { exact: true })).toBeVisible(); + await userEvent.click(body.getByRole("button", { name: "Continue with Google" })); + await expect(body.getByRole("status")).toHaveTextContent("No account was connected."); + }, +}; diff --git a/ui/storybook/stories/connection-setup/google-workspace.stories.tsx b/ui/storybook/stories/connection-setup/google-workspace.stories.tsx new file mode 100644 index 00000000000..21780a224f2 --- /dev/null +++ b/ui/storybook/stories/connection-setup/google-workspace.stories.tsx @@ -0,0 +1,53 @@ +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 = { + id: "apps-connections-google-workspace", + title: "Apps, channels, and extensions/Connection setup/Google Workspace", + parameters: { layout: "fullscreen", paperclip: { + capabilities: ["connection-setup/catalog", "app-permissions/account-maintenance"], + status: "current", implementation: "production", boundary: "fixtures", + notes: "Production connection setup with simulated gallery metadata. Mutations are rejected; no successful Google grant or persistence is simulated.", + } }, + 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 } } };