feat(connections): add unified Google Workspace connector

Use one OAuth grant and action profile for the nine existing Google services. Respect partial consent at discovery and dispatch, preserve legacy connections, and keep the review visibility hold.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 05:52:27 -05:00
1 parent 6dc32b57fb
commit 4219ebbb80
23 files changed
+821 -54

No files matched your search

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