From 46cea2cbb1f414ae58ac876819a51b11967909a6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:00:06 -0700 Subject: [PATCH 1/2] Add Gmail settings access --- .changeset/gmail-settings-access.md | 5 + apps/marketing/src/pages/google-oauth.astro | 4 +- .../src/pages/google-workspace.astro | 2 +- e2e/scenarios/first-party-oauth.test.ts | 17 ++- .../src/providers/google/discovery.test.ts | 114 +++++++++++++++++- .../openapi/src/providers/google/discovery.ts | 11 +- .../src/providers/google/oauth-scopes.test.ts | 11 +- .../src/providers/google/oauth-scopes.ts | 35 ++++-- .../src/providers/google/presets.test.ts | 6 +- .../openapi/src/providers/google/presets.ts | 5 +- .../google/spec-format-adapter.test.ts | 30 ++++- 11 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 .changeset/gmail-settings-access.md diff --git a/.changeset/gmail-settings-access.md b/.changeset/gmail-settings-access.md new file mode 100644 index 0000000000..8fe9f8e43a --- /dev/null +++ b/.changeset/gmail-settings-access.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Request Gmail's basic-settings scope alongside full mailbox access so Google integrations can create and manage Gmail filters without including domain-admin-only sharing settings. diff --git a/apps/marketing/src/pages/google-oauth.astro b/apps/marketing/src/pages/google-oauth.astro index a92c8a2e9f..06374a1bfe 100644 --- a/apps/marketing/src/pages/google-oauth.astro +++ b/apps/marketing/src/pages/google-oauth.astro @@ -13,8 +13,8 @@ const googleServices = [ index: "02", name: "Gmail", purpose: - "Executor can read, search, compose, send, organize, trash, and permanently delete messages only when you explicitly instruct an agent to work with your email.", - scope: "mail.google.com", + "Executor can read, search, compose, send, organize, trash, and permanently delete messages, and manage filters and other basic Gmail settings, only when you explicitly instruct an agent to work with your email.", + scope: "mail.google.com · gmail.settings.basic", }, { index: "03", diff --git a/apps/marketing/src/pages/google-workspace.astro b/apps/marketing/src/pages/google-workspace.astro index c6a6714949..e784b0ed7a 100644 --- a/apps/marketing/src/pages/google-workspace.astro +++ b/apps/marketing/src/pages/google-workspace.astro @@ -8,7 +8,7 @@ const services = [ { name: "Gmail", description: - "Read, search, compose, send, label, archive, trash, or permanently delete messages when you explicitly ask an agent to work with your email.", + "Read, search, compose, send, label, archive, trash, or permanently delete messages, and manage filters and other basic Gmail settings, when you explicitly ask an agent to work with your email.", }, { name: "Google Sheets", diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts index 7b9e93a868..c682c848b2 100644 --- a/e2e/scenarios/first-party-oauth.test.ts +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -223,6 +223,12 @@ scenario( ); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/gmail.modify"); expect(google.origin.allowedScopes).toContain("https://mail.google.com/"); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/gmail.settings.basic", + ); + expect(google.origin.allowedScopes).not.toContain( + "https://www.googleapis.com/auth/gmail.settings.sharing", + ); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/drive"); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/documents"); @@ -332,6 +338,7 @@ scenario( "email", "profile", "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", ]), slug: fullGmail, }, @@ -351,7 +358,15 @@ scenario( fullGmailStarted.status === "redirect" ? fullGmailStarted.authorizationUrl : ""; expect( new Set(new URL(fullGmailAuthorizationUrl).searchParams.get("scope")?.split(" ") ?? []), - ).toEqual(new Set(["openid", "email", "profile", "https://mail.google.com/"])); + ).toEqual( + new Set([ + "openid", + "email", + "profile", + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", + ]), + ); const drive = IntegrationSlug.make(unique("google_drive")); yield* client.openapi.addSpec({ diff --git a/packages/plugins/openapi/src/providers/google/discovery.test.ts b/packages/plugins/openapi/src/providers/google/discovery.test.ts index 5f4f2829a9..8e288a9bf0 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.test.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.test.ts @@ -1031,9 +1031,106 @@ it.effect("filters Gmail operations to the explicitly selected consent scope", ( }), ); +it.effect("keeps consumer Gmail settings tools alongside full mailbox access", () => + Effect.gen(function* () { + const fullScope = "https://mail.google.com/"; + const settingsBasicScope = "https://www.googleapis.com/auth/gmail.settings.basic"; + const settingsSharingScope = "https://www.googleapis.com/auth/gmail.settings.sharing"; + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ + consentScopes: [fullScope, settingsBasicScope], + documents: [ + { + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "gmail", + version: "v1", + title: "Gmail API", + rootUrl: "https://gmail.googleapis.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + [fullScope]: { description: "Full Gmail access" }, + [settingsBasicScope]: { description: "Manage Gmail settings" }, + [settingsSharingScope]: { description: "Manage Gmail sharing settings" }, + }, + }, + }, + resources: { + users: { + resources: { + messages: { + methods: { + delete: { + id: "gmail.users.messages.delete", + httpMethod: "DELETE", + path: "gmail/v1/users/{userId}/messages/{id}", + scopes: [fullScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + id: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + settings: { + resources: { + filters: { + methods: { + create: { + id: "gmail.users.settings.filters.create", + httpMethod: "POST", + path: "gmail/v1/users/{userId}/settings/filters", + scopes: [settingsBasicScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + forwardingAddresses: { + methods: { + create: { + id: "gmail.users.settings.forwardingAddresses.create", + httpMethod: "POST", + path: "gmail/v1/users/{userId}/settings/forwardingAddresses", + scopes: [settingsSharingScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + schemas: {}, + }), + }, + ], + }); + + const spec = decodeConvertedSpec(result.specText); + const operationIds = Object.values(spec.paths).flatMap((path) => + Object.values(path).map((operation) => operation.operationId), + ); + expect(operationIds).toContain("gmail.users.messages.delete"); + expect(operationIds).toContain("gmail.users.settings.filters.create"); + expect(operationIds).not.toContain("gmail.users.settings.forwardingAddresses.create"); + const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ + fullScope, + settingsBasicScope, + ]); + }), +); + // --------------------------------------------------------------------------- // The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes -// collapse under their broad parent (`gmail.*` → `mail.google.com/`, +// collapse under their broad parent (Gmail message scopes → `mail.google.com/`, // `calendar.*` → `calendar`, `userinfo.email` → `email`), and scopes a user // OAuth consent screen can't show (`chat.bot`, `chat.app.*`, `keep`) are // dropped. The persisted auth template, the spec `securitySchemes.googleOAuth2` @@ -1171,6 +1268,17 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent // Broad parent + a sub-scope that must collapse under it. "https://mail.google.com/": { description: "Full Gmail access" }, "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" }, + // Basic settings remain independent; admin-only sharing and + // contextual add-on scopes must not enter user consent. + "https://www.googleapis.com/auth/gmail.settings.basic": { + description: "Manage Gmail settings", + }, + "https://www.googleapis.com/auth/gmail.settings.sharing": { + description: "Manage Gmail sharing settings", + }, + "https://www.googleapis.com/auth/gmail.addons.current.message.readonly": { + description: "Read the current add-on message", + }, // Identity scope normalized to `email`. "https://www.googleapis.com/auth/userinfo.email": { description: "Email" }, }, @@ -1241,12 +1349,14 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent const expectedConsentScopes = [ "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", "email", "https://www.googleapis.com/auth/chat.spaces.readonly", ]; // The derived oauth auth template carries the compacted/filtered set - // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped). + // (gmail.readonly collapsed, settings.basic preserved, userinfo.email → email, + // and admin-only/contextual/chat scopes dropped). const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual( [...expectedConsentScopes].sort(), diff --git a/packages/plugins/openapi/src/providers/google/discovery.ts b/packages/plugins/openapi/src/providers/google/discovery.ts index 00c798b035..a10e85f0aa 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.ts @@ -569,11 +569,12 @@ const discoveryScopes = (document: DiscoveryDocument): Record => // requests at connect) must match the consent the picker previews. Both run the // raw Discovery union through `compactGoogleOAuthScopes`, which drops scopes a // user OAuth consent screen can't show (`chat.bot`/`chat.app.*`/`keep`) and -// collapses sub-scopes under their broad parent (`gmail.*` → `mail.google.com`, -// `userinfo.email` → `email`). Descriptions are preserved where the raw map had -// them; compaction-introduced identity scopes (`email`/`profile`) fall back to -// the broad parent's description. Per-operation `x-google-scopes`/`security` -// stay RAW - they describe which scope each method needs, not consent. +// collapses content sub-scopes under their broad parent (Gmail message scopes → +// `mail.google.com`, `userinfo.email` → `email`) while preserving independent +// settings scopes. Descriptions are preserved where the raw map had them; +// compaction-introduced identity scopes (`email`/`profile`) fall back to the +// broad parent's description. Per-operation `x-google-scopes`/`security` stay +// RAW - they describe which scope each method needs, not consent. const compactDiscoveryScopeMap = (raw: Record): Record => { const descriptionFor = (scope: string): string => { if (raw[scope] !== undefined) return raw[scope]; diff --git a/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts b/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts index 5118eb5b60..52a89643b2 100644 --- a/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts +++ b/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts @@ -24,11 +24,20 @@ it("compacts Google OAuth scopes after filtering user-consent-incompatible scope compactGoogleOAuthScopes([ "https://mail.google.com/", "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.settings.basic", + "https://www.googleapis.com/auth/gmail.settings.sharing", + "https://www.googleapis.com/auth/gmail.addons.current.message.readonly", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "openid", "https://www.googleapis.com/auth/chat.app.spaces", "https://www.googleapis.com/auth/keep.readonly", ]), - ).toEqual(["https://mail.google.com/", "email", "profile", "openid"]); + ).toEqual([ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", + "email", + "profile", + "openid", + ]); }); diff --git a/packages/plugins/openapi/src/providers/google/oauth-scopes.ts b/packages/plugins/openapi/src/providers/google/oauth-scopes.ts index 3a843b1a5d..302cb7b2a6 100644 --- a/packages/plugins/openapi/src/providers/google/oauth-scopes.ts +++ b/packages/plugins/openapi/src/providers/google/oauth-scopes.ts @@ -1,27 +1,47 @@ const googleUserConsentBlockedScopes = new Set([ "https://www.googleapis.com/auth/chat.bot", "https://www.googleapis.com/auth/chat.import", + // Gmail sharing-setting writes require a service account with domain-wide + // delegation, not the authorization-code flow used by user connections. + "https://www.googleapis.com/auth/gmail.settings.sharing", "https://www.googleapis.com/auth/keep", "https://www.googleapis.com/auth/keep.readonly", ]); -const googleUserConsentBlockedScopePrefixes = ["https://www.googleapis.com/auth/chat.app."]; +const googleUserConsentBlockedScopePrefixes = [ + "https://www.googleapis.com/auth/chat.app.", + // Contextual Gmail add-on scopes are minted for add-on executions, not a + // standalone web OAuth connection. + "https://www.googleapis.com/auth/gmail.addons.", +]; + +const googleMailScopesCoveredByFullAccess = new Set([ + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.insert", + "https://www.googleapis.com/auth/gmail.labels", + "https://www.googleapis.com/auth/gmail.metadata", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", +]); const googleBroadScopeGroups: readonly { readonly broad: string; - readonly prefixes: readonly string[]; + readonly covers: (scope: string) => boolean; }[] = [ { broad: "https://mail.google.com/", - prefixes: ["https://www.googleapis.com/auth/gmail."], + // Full mailbox access covers message, draft, label, and send operations, + // but Google requires gmail.settings.basic separately for filter writes. + covers: (scope) => googleMailScopesCoveredByFullAccess.has(scope), }, { broad: "https://www.googleapis.com/auth/calendar", - prefixes: ["https://www.googleapis.com/auth/calendar."], + covers: (scope) => scope.startsWith("https://www.googleapis.com/auth/calendar."), }, { broad: "https://www.googleapis.com/auth/drive", - prefixes: ["https://www.googleapis.com/auth/drive."], + covers: (scope) => scope.startsWith("https://www.googleapis.com/auth/drive."), }, ]; @@ -57,10 +77,7 @@ export const compactGoogleOAuthScopes = (scopes: Iterable): string[] => return ordered.filter( (scope) => !googleBroadScopeGroups.some( - (group) => - scope !== group.broad && - present.has(group.broad) && - group.prefixes.some((prefix) => scope.startsWith(prefix)), + (group) => scope !== group.broad && present.has(group.broad) && group.covers(scope), ), ); }; diff --git a/packages/plugins/openapi/src/providers/google/presets.test.ts b/packages/plugins/openapi/src/providers/google/presets.test.ts index db9bc9971a..3bffd06e94 100644 --- a/packages/plugins/openapi/src/providers/google/presets.test.ts +++ b/packages/plugins/openapi/src/providers/google/presets.test.ts @@ -215,14 +215,18 @@ it("keeps Select all limited to Google services that can use normal user OAuth", expect(standardIds).not.toContain("google-admin-reports"); }); -it("requests full Gmail and the complete user-facing Meet surface", () => { +it("requests full consumer Gmail access and the complete user-facing Meet surface", () => { const gmail = googleCatalog.find((preset) => preset.id === "google-gmail"); const meet = googleCatalog.find((preset) => preset.id === "google-meet"); const gmailOAuth = gmail?.authTemplate?.find((template) => template.kind === "oauth2"); const meetOAuth = meet?.authTemplate?.find((template) => template.kind === "oauth2"); expect(gmailOAuth?.scopes).toContain("https://mail.google.com/"); + expect(gmailOAuth?.scopes).toContain("https://www.googleapis.com/auth/gmail.settings.basic"); expect(gmailOAuth?.scopes).not.toContain("https://www.googleapis.com/auth/gmail.modify"); + expect(gmailOAuth?.scopes).not.toContain( + "https://www.googleapis.com/auth/gmail.settings.sharing", + ); expect(meetOAuth?.scopes).toEqual( expect.arrayContaining([ "https://www.googleapis.com/auth/meetings.space.created", diff --git a/packages/plugins/openapi/src/providers/google/presets.ts b/packages/plugins/openapi/src/providers/google/presets.ts index 140a2a2c64..db2b9bf57a 100644 --- a/packages/plugins/openapi/src/providers/google/presets.ts +++ b/packages/plugins/openapi/src/providers/google/presets.ts @@ -264,7 +264,10 @@ export const googleOAuthConsentScopes: Readonly +it.effect("preserves a Google preset's full consumer consent boundary when refreshing", () => Effect.gen(function* () { const gmailPreset = googleCatalog.find((preset) => preset.id === "google-gmail")!; const authTemplate: readonly AuthenticationInput[] = (gmailPreset.authTemplate ?? []).flatMap( @@ -255,7 +274,7 @@ it.effect("preserves a Google preset's consent scope boundary when refreshing", family: gmailPreset.family, authenticationTemplate: authTemplate, }); - expect(added.toolCount).toBe(1); + expect(added.toolCount).toBe(3); const updated = yield* executor.openapi.updateSpec("google_gmail"); @@ -263,10 +282,11 @@ it.effect("preserves a Google preset's consent scope boundary when refreshing", const oauthTemplate = config?.authenticationTemplate?.find( (template) => template.kind === "oauth2", ); - expect(updated.toolCount).toBe(1); + expect(updated.toolCount).toBe(3); expect(updated.addedTools).not.toContain("gmail.users.messages.delete"); - expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toContain( - GMAIL_MODIFY_SCOPE, + expect(updated.addedTools).not.toContain("gmail.users.settings.filters.create"); + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual( + expect.arrayContaining([GMAIL_FULL_SCOPE, GMAIL_SETTINGS_BASIC_SCOPE]), ); }), ); From 42d4e49ce8ee0c0885ba7826b0971c0a765f6dd9 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:39:23 -0700 Subject: [PATCH 2/2] Fix Google OAuth service policies --- apps/marketing/src/pages/about-executor.astro | 6 +- apps/marketing/src/pages/google-oauth.astro | 10 +- .../src/pages/google-workspace.astro | 6 +- apps/marketing/src/pages/privacy.astro | 4 +- e2e/scenarios/first-party-oauth.test.ts | 15 +- .../google/__snapshots__/presets.test.ts.snap | 2 +- .../src/providers/google/discovery.test.ts | 361 ++++++++++++++++-- .../openapi/src/providers/google/discovery.ts | 183 ++++++--- .../src/providers/google/oauth-scopes.test.ts | 26 ++ .../src/providers/google/oauth-scopes.ts | 83 +++- .../src/providers/google/presets.test.ts | 72 +++- .../openapi/src/providers/google/presets.ts | 109 +++--- .../providers/google/service-policy.test.ts | 32 ++ .../src/providers/google/service-policy.ts | 257 +++++++++++++ .../src/planner.test.ts | 1 - 15 files changed, 998 insertions(+), 169 deletions(-) create mode 100644 packages/plugins/openapi/src/providers/google/service-policy.test.ts create mode 100644 packages/plugins/openapi/src/providers/google/service-policy.ts diff --git a/apps/marketing/src/pages/about-executor.astro b/apps/marketing/src/pages/about-executor.astro index 007d27125f..bcc4d52282 100644 --- a/apps/marketing/src/pages/about-executor.astro +++ b/apps/marketing/src/pages/about-executor.astro @@ -124,11 +124,11 @@ const pageDescription = person's instructions.

    -
  • Google Calendar: read calendars and events, or create, update, and remove events.
  • +
  • Google Calendar: read, create, update, or remove calendars and events, and manage calendar sharing rules.
  • Gmail: read and search messages, compose and send mail, manage labels, archive or trash messages, or permanently delete messages only when explicitly requested.
  • -
  • Google Sheets: read spreadsheet data and update cells, ranges, and worksheets.
  • +
  • Google Sheets: read spreadsheet data, update cells, ranges, and worksheets, and write Drive-file smart chips.
  • Google Drive, Docs, Slides, and Forms: find and manage files and folders, edit documents and presentations, and create or read forms and responses.
  • -
  • Google Contacts and Tasks: read or update contacts and contact groups, read other contacts or an available Workspace directory, and manage task lists and tasks.
  • +
  • Google Contacts and Tasks: read or update contacts and contact groups, read available profile fields, other contacts, or a Workspace directory, and manage task lists and tasks.
  • Google Meet: create and configure meeting spaces or read meeting records, participants, recordings, and transcripts.
  • Google Photos: upload and manage app-created media or read media explicitly selected through Google Photos Picker.
  • Google Search Console: inspect verified sites, sitemaps, indexed URLs, and search-performance data.
  • diff --git a/apps/marketing/src/pages/google-oauth.astro b/apps/marketing/src/pages/google-oauth.astro index 06374a1bfe..a4b0802484 100644 --- a/apps/marketing/src/pages/google-oauth.astro +++ b/apps/marketing/src/pages/google-oauth.astro @@ -6,7 +6,7 @@ const googleServices = [ index: "01", name: "Google Calendar", purpose: - "Executor can read your calendars and events, then create, update, or remove events when you ask an agent to manage your schedule.", + "Executor can read, create, update, or remove calendars and events, and manage calendar sharing rules, when you ask an agent to manage your schedule.", scope: "googleapis.com/auth/calendar", }, { @@ -20,8 +20,8 @@ const googleServices = [ index: "03", name: "Google Sheets", purpose: - "Executor can read spreadsheet data and update cells, ranges, and worksheets when you ask an agent to work with a spreadsheet.", - scope: "googleapis.com/auth/spreadsheets", + "Executor can read spreadsheet data, update cells, ranges, and worksheets, and write Drive-file smart chips when you ask an agent to work with a spreadsheet.", + scope: "googleapis.com/auth/spreadsheets · drive.file", }, { index: "04", @@ -41,8 +41,8 @@ const googleServices = [ index: "06", name: "Google Contacts and Tasks", purpose: - "Executor can read or update contacts and contact groups, read other contacts or an available Workspace directory, and manage task lists and tasks when you ask an agent to organize people or work.", - scope: "contacts · contacts.other.readonly · directory.readonly · tasks", + "Executor can read or update contacts and contact groups, read available profile fields, other contacts, or a Workspace directory, and manage task lists and tasks when you ask an agent to organize people or work.", + scope: "contacts · user profile fields · directory.readonly · tasks", }, { index: "07", diff --git a/apps/marketing/src/pages/google-workspace.astro b/apps/marketing/src/pages/google-workspace.astro index e784b0ed7a..75133113ab 100644 --- a/apps/marketing/src/pages/google-workspace.astro +++ b/apps/marketing/src/pages/google-workspace.astro @@ -3,7 +3,7 @@ const services = [ { name: "Google Calendar", description: - "Read calendars and events, and create, update, or remove events when you ask an agent to manage your schedule.", + "Read, create, update, or remove calendars and events, and manage calendar sharing rules, when you ask an agent to manage your schedule.", }, { name: "Gmail", @@ -13,7 +13,7 @@ const services = [ { name: "Google Sheets", description: - "Read spreadsheet data and update cells, ranges, or worksheets when you ask an agent to work with a spreadsheet.", + "Read spreadsheet data, update cells, ranges, or worksheets, and write Drive-file smart chips when you ask an agent to work with a spreadsheet.", }, { name: "Google Drive, Docs, Slides, and Forms", @@ -23,7 +23,7 @@ const services = [ { name: "Google Contacts and Tasks", description: - "Read or update contacts and contact groups, read other contacts or an available Workspace directory, and manage task lists and tasks when you ask an agent to organize people or work.", + "Read or update contacts and contact groups, read available profile fields, other contacts, or a Workspace directory, and manage task lists and tasks when you ask an agent to organize people or work.", }, { name: "Google Meet", diff --git a/apps/marketing/src/pages/privacy.astro b/apps/marketing/src/pages/privacy.astro index e57eae4ab2..45edcd0cd3 100644 --- a/apps/marketing/src/pages/privacy.astro +++ b/apps/marketing/src/pages/privacy.astro @@ -57,9 +57,9 @@ import LegalLayout from "../components/LegalLayout.astro";

    If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you request through that integration. Depending on the service and permissions you choose, this may include accessing - or modifying Google Calendar events; Gmail messages, drafts, threads, attachments, labels, and settings; Google + or modifying Google Calendar calendars, events, and sharing rules; Gmail messages, drafts, threads, attachments, labels, and settings; Google Drive files and folders; Docs documents; Sheets spreadsheets; Slides presentations; Forms and responses; Contacts, - other contacts, and an available Workspace directory; Tasks; Meet spaces, participants, recordings, and transcripts; app-created or user-selected Photos media; Search + profile fields, other contacts, and an available Workspace directory; Tasks; Meet spaces, participants, recordings, and transcripts; app-created or user-selected Photos media; Search Console sites, sitemaps, indexed URLs, and performance data; or other Google content made available by the service you connect. For Gmail, this can include permanently deleting messages or threads only when you explicitly request that irreversible action. diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts index c682c848b2..5a8d4fc578 100644 --- a/e2e/scenarios/first-party-oauth.test.ts +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -221,7 +221,9 @@ scenario( expect(google.origin.allowedScopes).toContain( "https://www.googleapis.com/auth/meetings.space.readonly", ); - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/gmail.modify"); + expect(google.origin.allowedScopes).not.toContain( + "https://www.googleapis.com/auth/gmail.modify", + ); expect(google.origin.allowedScopes).toContain("https://mail.google.com/"); expect(google.origin.allowedScopes).toContain( "https://www.googleapis.com/auth/gmail.settings.basic", @@ -230,6 +232,7 @@ scenario( "https://www.googleapis.com/auth/gmail.settings.sharing", ); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/drive.file"); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/drive"); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/documents"); expect(google.origin.allowedScopes).toContain( @@ -247,6 +250,16 @@ scenario( expect(google.origin.allowedScopes).toContain( "https://www.googleapis.com/auth/directory.readonly", ); + for (const scope of [ + "user.addresses.read", + "user.birthday.read", + "user.emails.read", + "user.gender.read", + "user.organization.read", + "user.phonenumbers.read", + ]) { + expect(google.origin.allowedScopes).toContain(`https://www.googleapis.com/auth/${scope}`); + } expect(google.origin.allowedScopes).toContain( "https://www.googleapis.com/auth/photoslibrary.appendonly", ); diff --git a/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap b/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap index 60d14c031c..756a287b87 100644 --- a/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap +++ b/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap @@ -52,7 +52,7 @@ exports[`classifies every Google service for bundle OAuth UX 1`] = ` }, { "id": "google-chat", - "oauthAudience": "workspace-admin", + "oauthAudience": "advanced-user", }, { "id": "google-keep", diff --git a/packages/plugins/openapi/src/providers/google/discovery.test.ts b/packages/plugins/openapi/src/providers/google/discovery.test.ts index 8e288a9bf0..f48f70fc0e 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.test.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.test.ts @@ -8,6 +8,7 @@ import { isGoogleDiscoveryUrl, normalizeGoogleDiscoveryUrl, } from "./discovery"; +import { googleOAuthConsentScopesForPreset } from "./service-policy"; import { extract, parse } from "@executor-js/plugin-openapi"; const ConvertedOperation = Schema.Struct({ @@ -62,7 +63,9 @@ const oauth2DiscoveryDoc = { auth: { oauth2: { scopes: { - openid: { description: "Associate you with your personal info on Google" }, + openid: { + description: "Associate you with your personal info on Google", + }, "https://www.googleapis.com/auth/userinfo.email": { description: "See your primary Google Account email address", }, @@ -411,7 +414,9 @@ it.effect("converts Google OAuth2 v2 top-level and aliased userinfo methods", () "x-executor-toolPath": "userinfo.get", "x-google-scopes": [...OAUTH2_USERINFO_SCOPES], }); - expect(userinfo?.security).toEqual([{ googleOAuth2: [...OAUTH2_USERINFO_SCOPES] }]); + expect(userinfo?.security).toEqual( + OAUTH2_USERINFO_SCOPES.map((scope) => ({ googleOAuth2: [scope] })), + ); expect(userinfo?.responses).toMatchObject({ "200": { content: { @@ -454,8 +459,16 @@ it.effect("marks Google Discovery media-download methods as binary responses", ( supportsMediaDownload: true, useMediaDownloadService: true, parameters: { - fileId: { location: "path", required: true, type: "string" }, - mimeType: { location: "query", required: true, type: "string" }, + fileId: { + location: "path", + required: true, + type: "string", + }, + mimeType: { + location: "query", + required: true, + type: "string", + }, }, }, }, @@ -633,7 +646,11 @@ it.effect( }, scopes: ["https://www.googleapis.com/auth/drive.file"], parameters: { - fileId: { location: "path", required: true, type: "string" }, + fileId: { + location: "path", + required: true, + type: "string", + }, }, }, export: { @@ -643,8 +660,16 @@ it.effect( supportsMediaDownload: true, useMediaDownloadService: true, parameters: { - fileId: { location: "path", required: true, type: "string" }, - mimeType: { location: "query", required: true, type: "string" }, + fileId: { + location: "path", + required: true, + type: "string", + }, + mimeType: { + location: "query", + required: true, + type: "string", + }, }, }, }, @@ -953,8 +978,11 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI integratio // v2: the bundled oauth scopes are carried on the oauth auth template. const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ - "https://www.googleapis.com/auth/gmail.metadata", - "https://www.googleapis.com/auth/chat.spaces.readonly", + "openid", + "email", + "profile", + ...googleOAuthConsentScopesForPreset("google-gmail"), + ...googleOAuthConsentScopesForPreset("google-chat"), ]); }), ); @@ -994,7 +1022,11 @@ it.effect("filters Gmail operations to the explicitly selected consent scope", ( path: "gmail/v1/users/{userId}/messages", scopes: [modifyScope, fullScope], parameters: { - userId: { location: "path", required: true, type: "string" }, + userId: { + location: "path", + required: true, + type: "string", + }, }, }, delete: { @@ -1003,8 +1035,16 @@ it.effect("filters Gmail operations to the explicitly selected consent scope", ( path: "gmail/v1/users/{userId}/messages/{id}", scopes: [fullScope], parameters: { - userId: { location: "path", required: true, type: "string" }, - id: { location: "path", required: true, type: "string" }, + userId: { + location: "path", + required: true, + type: "string", + }, + id: { + location: "path", + required: true, + type: "string", + }, }, }, }, @@ -1052,8 +1092,12 @@ it.effect("keeps consumer Gmail settings tools alongside full mailbox access", ( oauth2: { scopes: { [fullScope]: { description: "Full Gmail access" }, - [settingsBasicScope]: { description: "Manage Gmail settings" }, - [settingsSharingScope]: { description: "Manage Gmail sharing settings" }, + [settingsBasicScope]: { + description: "Manage Gmail settings", + }, + [settingsSharingScope]: { + description: "Manage Gmail sharing settings", + }, }, }, }, @@ -1068,8 +1112,16 @@ it.effect("keeps consumer Gmail settings tools alongside full mailbox access", ( path: "gmail/v1/users/{userId}/messages/{id}", scopes: [fullScope], parameters: { - userId: { location: "path", required: true, type: "string" }, - id: { location: "path", required: true, type: "string" }, + userId: { + location: "path", + required: true, + type: "string", + }, + id: { + location: "path", + required: true, + type: "string", + }, }, }, }, @@ -1084,7 +1136,11 @@ it.effect("keeps consumer Gmail settings tools alongside full mailbox access", ( path: "gmail/v1/users/{userId}/settings/filters", scopes: [settingsBasicScope], parameters: { - userId: { location: "path", required: true, type: "string" }, + userId: { + location: "path", + required: true, + type: "string", + }, }, }, }, @@ -1097,7 +1153,11 @@ it.effect("keeps consumer Gmail settings tools alongside full mailbox access", ( path: "gmail/v1/users/{userId}/settings/forwardingAddresses", scopes: [settingsSharingScope], parameters: { - userId: { location: "path", required: true, type: "string" }, + userId: { + location: "path", + required: true, + type: "string", + }, }, }, }, @@ -1266,8 +1326,12 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent oauth2: { scopes: { // Broad parent + a sub-scope that must collapse under it. - "https://mail.google.com/": { description: "Full Gmail access" }, - "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" }, + "https://mail.google.com/": { + description: "Full Gmail access", + }, + "https://www.googleapis.com/auth/gmail.readonly": { + description: "Read Gmail", + }, // Basic settings remain independent; admin-only sharing and // contextual add-on scopes must not enter user consent. "https://www.googleapis.com/auth/gmail.settings.basic": { @@ -1280,7 +1344,9 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent description: "Read the current add-on message", }, // Identity scope normalized to `email`. - "https://www.googleapis.com/auth/userinfo.email": { description: "Email" }, + "https://www.googleapis.com/auth/userinfo.email": { + description: "Email", + }, }, }, }, @@ -1295,7 +1361,11 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent path: "gmail/v1/users/{userId}/messages", scopes: ["https://www.googleapis.com/auth/gmail.readonly"], parameters: { - userId: { location: "path", required: true, type: "string" }, + userId: { + location: "path", + required: true, + type: "string", + }, }, }, }, @@ -1320,9 +1390,15 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent scopes: { // A keepable consent scope plus two that the user-consent filter // must drop (`chat.bot`, `chat.app.*`). - "https://www.googleapis.com/auth/chat.spaces.readonly": { description: "Spaces" }, - "https://www.googleapis.com/auth/chat.bot": { description: "Bot" }, - "https://www.googleapis.com/auth/chat.app.spaces": { description: "App spaces" }, + "https://www.googleapis.com/auth/chat.spaces.readonly": { + description: "Spaces", + }, + "https://www.googleapis.com/auth/chat.bot": { + description: "Bot", + }, + "https://www.googleapis.com/auth/chat.app.spaces": { + description: "App spaces", + }, }, }, }, @@ -1335,7 +1411,11 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent path: "v1/{+name}", scopes: ["https://www.googleapis.com/auth/chat.bot"], parameters: { - name: { location: "path", required: true, type: "string" }, + name: { + location: "path", + required: true, + type: "string", + }, }, }, }, @@ -1348,15 +1428,15 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent }); const expectedConsentScopes = [ - "https://mail.google.com/", - "https://www.googleapis.com/auth/gmail.settings.basic", + "openid", "email", - "https://www.googleapis.com/auth/chat.spaces.readonly", + "profile", + ...googleOAuthConsentScopesForPreset("google-gmail"), + ...googleOAuthConsentScopesForPreset("google-chat"), ]; - // The derived oauth auth template carries the compacted/filtered set - // (gmail.readonly collapsed, settings.basic preserved, userinfo.email → email, - // and admin-only/contextual/chat scopes dropped). + // Known services use their audited consent bundle instead of treating the + // Discovery document's alternative scopes as a cumulative request. const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual( [...expectedConsentScopes].sort(), @@ -1373,10 +1453,219 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent expect([...(spec.security[0]?.["googleOAuth2"] ?? [])].sort()).toEqual( [...expectedConsentScopes].sort(), ); - // Per-operation x-google-scopes stay RAW - a dropped consent scope can still - // be the scope a given method advertises. - expect(spec.paths["/v1/{name}"]?.get?.["x-google-scopes"]).toEqual([ - "https://www.googleapis.com/auth/chat.bot", + // App-auth-only methods are unavailable to the ordinary user OAuth bundle. + expect(spec.paths["/v1/{name}"]?.get).toBeUndefined(); + }), +); + +it.effect("applies public Google schema and parameter corrections", () => + Effect.gen(function* () { + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ + documents: [ + { + discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "sheets", + version: "v4", + title: "Google Sheets API", + rootUrl: "https://sheets.googleapis.com/", + servicePath: "", + schemas: { + Request: { + type: "object", + properties: { + addSheet: { type: "object" }, + addDataSource: { type: "object" }, + updateDataSource: { type: "object" }, + refreshDataSource: { type: "object" }, + cancelDataSourceRefresh: { type: "object" }, + }, + }, + }, + }), + }, + { + discoveryUrl: "https://photospicker.googleapis.com/$discovery/rest?version=v1", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "photospicker", + version: "v1", + title: "Google Photos Picker API", + rootUrl: "https://photospicker.googleapis.com/", + servicePath: "", + parameters: { + key: { location: "query", type: "string" }, + oauth_token: { location: "query", type: "string" }, + }, + resources: { + mediaItems: { + methods: { + list: { + id: "photospicker.mediaItems.list", + httpMethod: "GET", + path: "v1/mediaItems", + parameters: { + sessionId: { + location: "query", + type: "string", + description: "Required. The picker session identifier.", + }, + }, + }, + }, + }, + }, + schemas: { + PickingConfig: { + type: "object", + properties: { + maxItemCount: { type: "integer" }, + showEducationBanner: { type: "boolean" }, + showZeroState: { type: "boolean" }, + showExpandedAppBar: { type: "boolean" }, + }, + }, + }, + }), + }, + ], + }); + + const spec = decodeConvertedSpec(result.specText); + const sheetsRequest = spec.components.schemas["sheets_v4_Request"] as { + properties?: Record; + }; + expect(Object.keys(sheetsRequest.properties ?? {})).toEqual(["addSheet"]); + + const pickingConfig = spec.components.schemas["photospicker_v1_PickingConfig"] as { + properties?: Record; + }; + expect(Object.keys(pickingConfig.properties ?? {})).toEqual(["maxItemCount"]); + + const pickerList = spec.paths["/v1/mediaItems"]?.get; + expect(pickerList?.parameters).toContainEqual( + expect.objectContaining({ + name: "sessionId", + in: "query", + required: true, + }), + ); + expect(pickerList?.parameters.map((parameter) => parameter.name)).not.toContain("key"); + expect(pickerList?.parameters.map((parameter) => parameter.name)).not.toContain("oauth_token"); + }), +); + +it.effect("uses the preferred BigQuery grant for headless Discovery imports", () => + Effect.gen(function* () { + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ + documents: [ + { + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/bigquery/v2/rest", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "bigquery", + version: "v2", + title: "BigQuery API", + rootUrl: "https://bigquery.googleapis.com/", + servicePath: "bigquery/v2/", + auth: { + oauth2: { + scopes: { + "https://www.googleapis.com/auth/bigquery": { + description: "BigQuery", + }, + "https://www.googleapis.com/auth/cloud-platform": { + description: "Cloud", + }, + "https://www.googleapis.com/auth/devstorage.read_write": { + description: "Storage", + }, + }, + }, + }, + schemas: {}, + }), + }, + ], + }); + + const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/bigquery", ]); }), ); + +it.effect("marks People API semantic inputs as required", () => + Effect.gen(function* () { + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ + documents: [ + { + discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "people", + version: "v1", + title: "People API", + rootUrl: "https://people.googleapis.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + "https://www.googleapis.com/auth/contacts": { description: "Contacts" }, + }, + }, + }, + resources: { + people: { + methods: { + get: { + id: "people.people.get", + httpMethod: "GET", + path: "v1/{+resourceName}", + scopes: ["https://www.googleapis.com/auth/contacts"], + parameters: { + resourceName: { location: "path", required: true, type: "string" }, + personFields: { location: "query", type: "string" }, + }, + }, + batchCreateContacts: { + id: "people.people.batchCreateContacts", + httpMethod: "POST", + path: "v1/people:batchCreateContacts", + scopes: ["https://www.googleapis.com/auth/contacts"], + request: { $ref: "BatchCreateContactsRequest" }, + }, + }, + }, + }, + schemas: { + BatchCreateContactsRequest: { + type: "object", + properties: { + contacts: { type: "array", items: { type: "object" } }, + readMask: { type: "string" }, + }, + }, + }, + }), + }, + ], + }); + + const spec = decodeConvertedSpec(result.specText); + expect(spec.paths["/v1/{resourceName}"]?.get?.parameters).toContainEqual( + expect.objectContaining({ name: "personFields", required: true }), + ); + + const batchCreate = spec.paths["/v1/people:batchCreateContacts"]?.post; + expect(batchCreate?.requestBody).toMatchObject({ required: true }); + expect(spec.components.schemas["people_v1_BatchCreateContactsRequest"]).toMatchObject({ + required: ["contacts", "readMask"], + }); + }), +); diff --git a/packages/plugins/openapi/src/providers/google/discovery.ts b/packages/plugins/openapi/src/providers/google/discovery.ts index a10e85f0aa..87bf0c7d8a 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.ts @@ -7,7 +7,12 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { OpenApiParseError } from "../../sdk/errors"; import type { Authentication } from "../../sdk/types"; -import { compactGoogleOAuthScopes } from "./oauth-scopes"; +import { compactGoogleOAuthScopes, isGoogleUserConsentOAuthScope } from "./oauth-scopes"; +import { + googleDiscoveryPolicyFor, + isGoogleDiscoveryMethodAllowed, + type GoogleDiscoveryServicePolicy, +} from "./service-policy"; import { AuthTemplateSlug } from "@executor-js/sdk/shared"; interface SpecFetchCredentials { @@ -19,10 +24,8 @@ const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_IDENTITY_SCOPES: readonly string[] = ["openid", "email", "profile"]; const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker"; -const GOOGLE_PHOTOS_PICKER_SCOPE = - "https://www.googleapis.com/auth/photospicker.mediaitems.readonly"; -const GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION = "Read selected Google Photos media"; const OPENAPI_SCHEMA_TYPES = new Set([ "array", "boolean", @@ -35,8 +38,6 @@ const OPENAPI_SCHEMA_TYPES = new Set([ type GoogleDiscoveryServiceOverride = { readonly preserveServiceHostedUrl?: true; - readonly scopes?: Record; - readonly fallbackMethodScopes?: readonly string[]; }; const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record = { @@ -44,10 +45,6 @@ const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record const discoverySchemaToOpenApiSchema = ( raw: unknown, schemaNameForRef: (name: string) => string = identitySchemaName, + hiddenProperties?: ReadonlySet, + requiredProperties?: ReadonlySet, ): OpenApiSchemaObject => { if (!isRecord(raw)) return {}; const schema = raw; @@ -507,13 +506,21 @@ const discoverySchemaToOpenApiSchema = ( ) { const convertedProperties = isRecord(properties) ? Object.fromEntries( - Object.entries(properties).map(([name, value]) => [ - name, - discoverySchemaToOpenApiSchema(value, schemaNameForRef), - ]), + Object.entries(properties) + .filter(([name]) => !hiddenProperties?.has(name)) + .map(([name, value]) => [ + name, + discoverySchemaToOpenApiSchema(value, schemaNameForRef), + ]), ) : undefined; - const required = stringArray(schema.required); + const required = [ + ...(stringArray(schema.required) ?? []), + ...(requiredProperties ?? []), + ].filter( + (name, index, values) => + values.indexOf(name) === index && convertedProperties?.[name] !== undefined, + ); const additionalProperties = schema.additionalProperties === undefined ? undefined @@ -663,20 +670,26 @@ const buildDiscoveryOperation = (input: { readonly schemaNameForRef?: (name: string) => string; readonly serverUrl?: string; readonly tags?: readonly string[]; + readonly policy?: GoogleDiscoveryServicePolicy; }): OpenApiOperationObject => { const mergedParameters = new Map(); for (const [name, raw] of Object.entries(input.document.parameters ?? {})) { const parameter = decodeDiscoveryParameter(raw); - if (parameter.location) mergedParameters.set(name, parameter); + if (parameter.location && !input.policy?.hiddenParameters?.has(name)) { + mergedParameters.set(name, parameter); + } } for (const [name, raw] of Object.entries(input.method.parameters ?? {})) { const parameter = decodeDiscoveryParameter(raw); - if (parameter.location) mergedParameters.set(name, parameter); + if (parameter.location && !input.policy?.hiddenParameters?.has(name)) { + mergedParameters.set(name, parameter); + } } const methodScopes = input.oauthScopes ?? input.method.scopes ?? []; const methodDescription = Option.getOrUndefined(input.method.description); const schemaNameForRef = input.schemaNameForRef ?? identitySchemaName; + const policyMethodId = Option.getOrUndefined(input.method.id) ?? input.toolPath; return { operationId: input.toolPath, @@ -695,7 +708,10 @@ const buildDiscoveryOperation = (input: { { name, in: location, - required: location === "path" ? true : parameter.required === true, + required: + location === "path" || + parameter.required === true || + input.policy?.requiredParameters?.[policyMethodId]?.has(name) === true, ...(description !== undefined ? { description } : {}), schema: parameterSchema(parameter, schemaNameForRef), ...(location === "query" @@ -708,10 +724,12 @@ const buildDiscoveryOperation = (input: { ...(input.method.request?.$ref ? { requestBody: { - required: false, + required: input.policy?.requiredRequestBodies?.has(policyMethodId) === true, content: { "application/json": { - schema: { $ref: schemaRef(schemaNameForRef(input.method.request.$ref)) }, + schema: { + $ref: schemaRef(schemaNameForRef(input.method.request.$ref)), + }, }, }, }, @@ -723,7 +741,9 @@ const buildDiscoveryOperation = (input: { content: googleDiscoveryResponseContent(input.method, schemaNameForRef), }, }, - ...(methodScopes.length > 0 ? { security: [{ googleOAuth2: methodScopes }] } : {}), + ...(methodScopes.length > 0 + ? { security: methodScopes.map((scope) => ({ googleOAuth2: [scope] })) } + : {}), "x-google-scopes": methodScopes, }; }; @@ -736,6 +756,7 @@ const buildDiscoveryMediaUploadOperation = (input: { readonly schemaNameForRef?: (name: string) => string; readonly serverUrl?: string; readonly tags?: readonly string[]; + readonly policy?: GoogleDiscoveryServicePolicy; }): OpenApiOperationObject | undefined => { if (input.method.supportsMediaUpload !== true) return undefined; const mediaUpload = input.method.mediaUpload; @@ -747,11 +768,15 @@ const buildDiscoveryMediaUploadOperation = (input: { const mergedParameters = new Map(); for (const [name, raw] of Object.entries(input.document.parameters ?? {})) { const parameter = decodeDiscoveryParameter(raw); - if (parameter.location) mergedParameters.set(name, parameter); + if (parameter.location && !input.policy?.hiddenParameters?.has(name)) { + mergedParameters.set(name, parameter); + } } for (const [name, raw] of Object.entries(input.method.parameters ?? {})) { const parameter = decodeDiscoveryParameter(raw); - if (parameter.location) mergedParameters.set(name, parameter); + if (parameter.location && !input.policy?.hiddenParameters?.has(name)) { + mergedParameters.set(name, parameter); + } } mergedParameters.set("uploadType", { type: "string", @@ -772,6 +797,7 @@ const buildDiscoveryMediaUploadOperation = (input: { const methodScopes = input.oauthScopes ?? input.method.scopes ?? []; const schemaNameForRef = input.schemaNameForRef ?? identitySchemaName; const methodDescription = Option.getOrUndefined(input.method.description); + const policyMethodId = Option.getOrUndefined(input.method.id) ?? input.toolPath; return { operationId: `${input.toolPath}Media`, @@ -791,7 +817,10 @@ const buildDiscoveryMediaUploadOperation = (input: { { name, in: location, - required: location === "path" ? true : parameter.required === true, + required: + location === "path" || + parameter.required === true || + input.policy?.requiredParameters?.[policyMethodId]?.has(name) === true, ...(description !== undefined ? { description } : {}), schema: parameterSchema(parameter, schemaNameForRef), ...(location === "query" @@ -815,13 +844,17 @@ const buildDiscoveryMediaUploadOperation = (input: { content: { "application/json": { schema: input.method.response?.$ref - ? { $ref: schemaRef(schemaNameForRef(input.method.response.$ref)) } + ? { + $ref: schemaRef(schemaNameForRef(input.method.response.$ref)), + } : {}, }, }, }, }, - ...(methodScopes.length > 0 ? { security: [{ googleOAuth2: methodScopes }] } : {}), + ...(methodScopes.length > 0 + ? { security: methodScopes.map((scope) => ({ googleOAuth2: [scope] })) } + : {}), "x-google-scopes": methodScopes, }; }; @@ -834,29 +867,47 @@ const GOOGLE_PHOTOS_UPLOAD_PATH = "/v1/uploads"; const discoveryScopesForService = ( service: string, + version: string, document: DiscoveryDocument, ): Record => { const scopes = discoveryScopes(document); - const overrideScopes = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.scopes; + const overrideScopes = googleDiscoveryPolicyFor(service, version)?.authoritativeScopes; if (!overrideScopes) { return scopes; } - const missingScopes = Object.fromEntries( - Object.entries(overrideScopes).filter(([scope]) => scopes[scope] === undefined), - ); - return Object.keys(missingScopes).length === 0 ? scopes : { ...scopes, ...missingScopes }; + return { ...overrideScopes }; }; const discoveryMethodScopesForService = ( service: string, + version: string, method: DiscoveryMethod, ): readonly string[] => { const scopes = method.scopes ?? []; - return scopes.length === 0 - ? (GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.fallbackMethodScopes ?? scopes) + const policy = googleDiscoveryPolicyFor(service, version); + if (scopes.length === 0) return policy?.fallbackMethodScopes ?? scopes; + const authoritativeScopes = policy?.authoritativeScopes; + return authoritativeScopes + ? scopes.filter((scope) => authoritativeScopes[scope] !== undefined) : scopes; }; +const googleScopeCovers = (consentScope: string, methodScope: string): boolean => { + if (consentScope === methodScope) return true; + if (!isGoogleUserConsentOAuthScope(methodScope)) return false; + return !compactGoogleOAuthScopes([consentScope, methodScope]).includes(methodScope); +}; + +const oauthScopesForMethod = ( + methodScopes: readonly string[], + consentScopeSet: ReadonlySet | null, +): readonly string[] => + consentScopeSet === null + ? methodScopes + : [...consentScopeSet].filter((consentScope) => + methodScopes.some((methodScope) => googleScopeCovers(consentScope, methodScope)), + ); + /** The v2 oauth auth template for a Google-discovery integration. The spec * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the * catalog-level template a connection's access token renders through. */ @@ -931,7 +982,11 @@ const googlePhotosUploadOperation = (input: { }, }, }, - ...(input.oauthScopes.length > 0 ? { security: [{ googleOAuth2: input.oauthScopes }] } : {}), + ...(input.oauthScopes.length > 0 + ? { + security: input.oauthScopes.map((scope) => ({ googleOAuth2: [scope] })), + } + : {}), "x-google-scopes": input.oauthScopes, }); @@ -964,11 +1019,14 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD const info = yield* discoveryDocumentInfo(document, input.discoveryUrl); const { service, version, rootUrl, baseUrl, title } = info; const paths: Record> = {}; + const policy = googleDiscoveryPolicyFor(service, version); for (const method of allDiscoveryMethods(document)) { const methodId = Option.getOrUndefined(method.id); const pathTemplate = Option.getOrUndefined(method.path); if (!methodId || !pathTemplate || !method.httpMethod) continue; + if (!isGoogleDiscoveryMethodAllowed(policy, methodId)) continue; + const methodScopes = discoveryMethodScopesForService(service, version, method); const toolPath = methodToolPath(service, methodId); const path = normalizeDiscoveryPathTemplate( @@ -983,15 +1041,17 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD method, toolPath, pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`, - oauthScopes: discoveryMethodScopesForService(service, method), + oauthScopes: methodScopes, + policy, }); const mediaUploadOperation = buildDiscoveryMediaUploadOperation({ document, method, toolPath, - oauthScopes: discoveryMethodScopesForService(service, method), + oauthScopes: methodScopes, serverUrl: rootUrl, + policy, }); if (mediaUploadOperation) { const mediaUploadPathTemplate = mediaUploadOperation["x-executor-pathTemplate"] ?? ""; @@ -1024,7 +1084,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD }); } - const scopes = compactDiscoveryScopeMap(discoveryScopesForService(service, document)); + const scopes = compactDiscoveryScopeMap(discoveryScopesForService(service, version, document)); const authenticationTemplate = googleOauthTemplate(scopes); const spec: OpenApiDocument = { @@ -1039,7 +1099,12 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD schemas: Object.fromEntries( Object.entries(document.schemas ?? {}).map(([name, schema]) => [ name, - discoverySchemaToOpenApiSchema(schema), + discoverySchemaToOpenApiSchema( + schema, + identitySchemaName, + policy?.hiddenSchemaProperties?.[name], + policy?.requiredSchemaProperties?.[name], + ), ]), ), ...(authenticationTemplate @@ -1083,7 +1148,10 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( "OpenApi.convertGoogleDiscoveryBundle", )(function* (input: { - readonly documents: readonly { readonly discoveryUrl: string; readonly documentText: string }[]; + readonly documents: readonly { + readonly discoveryUrl: string; + readonly documentText: string; + }[]; readonly consentScopes?: readonly string[]; }) { if (input.documents.length === 0) { @@ -1116,12 +1184,29 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( const paths: Record> = {}; const schemas: Record = {}; const rawScopes: Record = {}; - const consentScopeSet = input.consentScopes ? new Set(input.consentScopes) : null; + const inferredConsentScopes = infos.every( + (info) => googleDiscoveryPolicyFor(info.service, info.version) !== undefined, + ) + ? [ + ...GOOGLE_IDENTITY_SCOPES, + ...infos.flatMap( + (info) => googleDiscoveryPolicyFor(info.service, info.version)?.consentScopes ?? [], + ), + ] + : undefined; + const effectiveConsentScopes = input.consentScopes ?? inferredConsentScopes; + const consentScopeSet = + effectiveConsentScopes === undefined ? null : new Set(effectiveConsentScopes); + + for (const scope of effectiveConsentScopes ?? []) { + rawScopes[scope] ??= ""; + } for (const info of infos) { + const policy = googleDiscoveryPolicyFor(info.service, info.version); const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; - const scopeDescriptions = discoveryScopesForService(info.service, info.document); + const scopeDescriptions = discoveryScopesForService(info.service, info.version, info.document); const filterConsentScopes = consentScopeSet !== null; for (const [scope, description] of Object.entries(scopeDescriptions)) { @@ -1130,17 +1215,21 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( } for (const [name, schema] of Object.entries(info.document.schemas ?? {})) { - schemas[schemaNameForRef(name)] = discoverySchemaToOpenApiSchema(schema, schemaNameForRef); + schemas[schemaNameForRef(name)] = discoverySchemaToOpenApiSchema( + schema, + schemaNameForRef, + policy?.hiddenSchemaProperties?.[name], + policy?.requiredSchemaProperties?.[name], + ); } for (const method of allDiscoveryMethods(info.document)) { const methodId = Option.getOrUndefined(method.id); const rawPathTemplate = Option.getOrUndefined(method.path); if (!methodId || !rawPathTemplate || !method.httpMethod) continue; - const methodScopes = discoveryMethodScopesForService(info.service, method); - const oauthScopes = filterConsentScopes - ? methodScopes.filter((scope) => consentScopeSet.has(scope)) - : methodScopes; + if (!isGoogleDiscoveryMethodAllowed(policy, methodId)) continue; + const methodScopes = discoveryMethodScopesForService(info.service, info.version, method); + const oauthScopes = oauthScopesForMethod(methodScopes, consentScopeSet); if (filterConsentScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue; const toolPath = methodId; @@ -1159,6 +1248,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( schemaNameForRef, serverUrl: info.baseUrl, tags: [info.title], + policy, }); const mediaUploadOperation = buildDiscoveryMediaUploadOperation({ @@ -1169,6 +1259,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( schemaNameForRef, serverUrl: info.rootUrl, tags: [info.title], + policy, }); if (mediaUploadOperation) { const mediaUploadPathTemplate = mediaUploadOperation["x-executor-pathTemplate"] ?? ""; diff --git a/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts b/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts index 52a89643b2..2e01631557 100644 --- a/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts +++ b/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts @@ -6,6 +6,7 @@ it("filters Google scopes that cannot be shown on a user OAuth consent screen", expect( filterGoogleUserConsentOAuthScopes([ "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/calendar.addons.execute", "https://www.googleapis.com/auth/chat.app.messages.readonly", "https://www.googleapis.com/auth/chat.bot", "https://www.googleapis.com/auth/chat.import", @@ -19,6 +20,31 @@ it("filters Google scopes that cannot be shown on a user OAuth consent screen", ]); }); +it("only compacts scopes that a broad Google scope actually covers", () => { + expect( + compactGoogleOAuthScopes([ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.apps.readonly", + "https://www.googleapis.com/auth/drive.activity.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/tasks", + "https://www.googleapis.com/auth/tasks.readonly", + "https://www.googleapis.com/auth/webmasters", + "https://www.googleapis.com/auth/webmasters.readonly", + ]), + ).toEqual([ + "https://www.googleapis.com/auth/drive", + "https://www.googleapis.com/auth/drive.apps.readonly", + "https://www.googleapis.com/auth/drive.activity.readonly", + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/tasks", + "https://www.googleapis.com/auth/webmasters", + ]); +}); + it("compacts Google OAuth scopes after filtering user-consent-incompatible scopes", () => { expect( compactGoogleOAuthScopes([ diff --git a/packages/plugins/openapi/src/providers/google/oauth-scopes.ts b/packages/plugins/openapi/src/providers/google/oauth-scopes.ts index 302cb7b2a6..9e111786ed 100644 --- a/packages/plugins/openapi/src/providers/google/oauth-scopes.ts +++ b/packages/plugins/openapi/src/providers/google/oauth-scopes.ts @@ -10,6 +10,8 @@ const googleUserConsentBlockedScopes = new Set([ const googleUserConsentBlockedScopePrefixes = [ "https://www.googleapis.com/auth/chat.app.", + // Contextual Calendar add-on scopes are minted for add-on executions. + "https://www.googleapis.com/auth/calendar.addons.", // Contextual Gmail add-on scopes are minted for add-on executions, not a // standalone web OAuth connection. "https://www.googleapis.com/auth/gmail.addons.", @@ -25,6 +27,79 @@ const googleMailScopesCoveredByFullAccess = new Set([ "https://www.googleapis.com/auth/gmail.send", ]); +const auth = (scope: string): string => `https://www.googleapis.com/auth/${scope}`; + +const googleDriveScopesCoveredByFullAccess = new Set([ + auth("drive.appdata"), + auth("drive.file"), + auth("drive.meet.readonly"), + auth("drive.metadata"), + auth("drive.metadata.readonly"), + auth("drive.photos.readonly"), + auth("drive.readonly"), + auth("drive.scripts"), +]); + +const exactBroadScopeGroups: Readonly>> = { + [auth("documents")]: new Set([auth("documents.readonly")]), + [auth("presentations")]: new Set([auth("presentations.readonly")]), + [auth("spreadsheets")]: new Set([auth("spreadsheets.readonly")]), + [auth("forms.body")]: new Set([auth("forms.body.readonly")]), + [auth("tasks")]: new Set([auth("tasks.readonly")]), + [auth("contacts")]: new Set([auth("contacts.readonly")]), + [auth("chat.spaces")]: new Set([auth("chat.spaces.readonly")]), + [auth("chat.memberships")]: new Set([auth("chat.memberships.readonly")]), + [auth("chat.messages")]: new Set([auth("chat.messages.readonly")]), + [auth("chat.customemojis")]: new Set([auth("chat.customemojis.readonly")]), + [auth("webmasters")]: new Set([auth("webmasters.readonly")]), + [auth("cloud-platform")]: new Set([auth("cloud-platform.read-only")]), + [auth("script.projects")]: new Set([auth("script.projects.readonly")]), + [auth("script.deployments")]: new Set([auth("script.deployments.readonly")]), + [auth("classroom.announcements")]: new Set([auth("classroom.announcements.readonly")]), + [auth("classroom.courses")]: new Set([auth("classroom.courses.readonly")]), + [auth("classroom.coursework.me")]: new Set([ + auth("classroom.coursework.me.readonly"), + auth("classroom.student-submissions.me.readonly"), + ]), + [auth("classroom.coursework.students")]: new Set([ + auth("classroom.coursework.students.readonly"), + auth("classroom.student-submissions.students.readonly"), + ]), + [auth("classroom.courseworkmaterials")]: new Set([ + auth("classroom.courseworkmaterials.readonly"), + ]), + [auth("classroom.rosters")]: new Set([auth("classroom.rosters.readonly")]), + [auth("classroom.topics")]: new Set([auth("classroom.topics.readonly")]), + [auth("admin.chrome.printers")]: new Set([auth("admin.chrome.printers.readonly")]), + [auth("admin.directory.customer")]: new Set([auth("admin.directory.customer.readonly")]), + [auth("admin.directory.device.chromeos")]: new Set([ + auth("admin.directory.device.chromeos.readonly"), + ]), + [auth("admin.directory.device.mobile")]: new Set([ + auth("admin.directory.device.mobile.action"), + auth("admin.directory.device.mobile.readonly"), + ]), + [auth("admin.directory.domain")]: new Set([auth("admin.directory.domain.readonly")]), + [auth("admin.directory.group")]: new Set([ + auth("admin.directory.group.readonly"), + auth("admin.directory.group.member"), + auth("admin.directory.group.member.readonly"), + ]), + [auth("admin.directory.orgunit")]: new Set([auth("admin.directory.orgunit.readonly")]), + [auth("admin.directory.resource.calendar")]: new Set([ + auth("admin.directory.resource.calendar.readonly"), + ]), + [auth("admin.directory.rolemanagement")]: new Set([ + auth("admin.directory.rolemanagement.readonly"), + ]), + [auth("admin.directory.user")]: new Set([ + auth("admin.directory.user.alias"), + auth("admin.directory.user.alias.readonly"), + auth("admin.directory.user.readonly"), + ]), + [auth("admin.directory.userschema")]: new Set([auth("admin.directory.userschema.readonly")]), +}; + const googleBroadScopeGroups: readonly { readonly broad: string; readonly covers: (scope: string) => boolean; @@ -41,8 +116,14 @@ const googleBroadScopeGroups: readonly { }, { broad: "https://www.googleapis.com/auth/drive", - covers: (scope) => scope.startsWith("https://www.googleapis.com/auth/drive."), + // Do not swallow independent scopes such as drive.apps.readonly, + // drive.activity, drive.install, or future drive.* scopes. + covers: (scope) => googleDriveScopesCoveredByFullAccess.has(scope), }, + ...Object.entries(exactBroadScopeGroups).map(([broad, covered]) => ({ + broad, + covers: (scope: string) => covered.has(scope), + })), ]; const normalizeGoogleIdentityScope = (scope: string): string => diff --git a/packages/plugins/openapi/src/providers/google/presets.test.ts b/packages/plugins/openapi/src/providers/google/presets.test.ts index 3bffd06e94..3aeedd1a0b 100644 --- a/packages/plugins/openapi/src/providers/google/presets.test.ts +++ b/packages/plugins/openapi/src/providers/google/presets.test.ts @@ -3,7 +3,12 @@ import { Effect } from "effect"; import { compileOpenApiSpec } from "@executor-js/plugin-openapi"; import { convertGoogleDiscoveryBundleToOpenApi } from "./discovery"; -import { googleCatalog, googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets"; +import { + googleCatalog, + googleOAuthConsentScopes, + googleOpenApiPresets, + googleStandardUserOAuthPresets, +} from "./presets"; const googleHealthCheckDiscoveryFixtures = { "google-calendar": { @@ -186,7 +191,6 @@ const FROZEN_GOOGLE_SLUGS = [ "google_photos_library", "google_photos_picker", "google_chat", - "google_keep", "google_youtube_data", "google_search_console", "google_classroom", @@ -261,10 +265,45 @@ it("requests every scope needed by Forms, People, and app-created Photos", () => "https://www.googleapis.com/auth/contacts", "https://www.googleapis.com/auth/contacts.other.readonly", "https://www.googleapis.com/auth/directory.readonly", + "https://www.googleapis.com/auth/user.addresses.read", + "https://www.googleapis.com/auth/user.birthday.read", + "https://www.googleapis.com/auth/user.emails.read", + "https://www.googleapis.com/auth/user.gender.read", + "https://www.googleapis.com/auth/user.organization.read", + "https://www.googleapis.com/auth/user.phonenumbers.read", ]), ); }); +it("uses the audited full-action scope unions for expanded Google services", () => { + expect(googleOAuthConsentScopes["google-chat"]).toHaveLength(9); + expect(googleOAuthConsentScopes["google-classroom"]).toHaveLength(11); + expect(googleOAuthConsentScopes["google-admin-directory"]).toHaveLength(12); + expect(googleOAuthConsentScopes["google-admin-reports"]).toEqual([ + "https://www.googleapis.com/auth/admin.reports.audit.readonly", + "https://www.googleapis.com/auth/admin.reports.usage.readonly", + ]); + expect(googleOAuthConsentScopes["google-apps-script"]).toEqual([ + "https://www.googleapis.com/auth/script.projects", + "https://www.googleapis.com/auth/script.deployments", + "https://www.googleapis.com/auth/script.processes", + "https://www.googleapis.com/auth/script.metrics", + ]); + expect(googleOAuthConsentScopes["google-youtube-data"]).toEqual([ + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.channel-memberships.creator", + ]); + expect(googleOAuthConsentScopes["google-sheets"]).toEqual([ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.file", + ]); +}); + +it("does not publish the domain-wide-delegation-only Keep preset", () => { + expect(googleOpenApiPresets.some((preset) => preset.id === "google-keep")).toBe(true); + expect(googleCatalog.some((preset) => preset.id === "google-keep")).toBe(false); +}); + it("classifies every Google service for bundle OAuth UX", () => { expect( googleOpenApiPresets.map((preset) => ({ @@ -299,7 +338,12 @@ it.effect("resolves fixture-backed Google catalog health checks in converted spe expect(preset?.healthCheck, `${presetId} declares a health check`).toBeTruthy(); const converted = yield* convertGoogleDiscoveryBundleToOpenApi({ - documents: [{ discoveryUrl: fixture.url, documentText: JSON.stringify(fixture.document) }], + documents: [ + { + discoveryUrl: fixture.url, + documentText: JSON.stringify(fixture.document), + }, + ], }); const compiled = yield* compileOpenApiSpec(converted.specText); expect(compiled.definitions.map((definition) => definition.toolPath)).toContain( @@ -325,7 +369,6 @@ it("omits Google health checks when the service spec has no stable cheap read", "google-slides", "google-forms", "google-photos-picker", - "google-admin-reports", ]; for (const presetId of omitted) { @@ -333,3 +376,24 @@ it("omits Google health checks when the service spec has no stable cheap read", expect(preset?.healthCheck, `${presetId} should not declare a health check`).toBeUndefined(); } }); + +it("uses bounded, non-mutating health probes for audited services", () => { + const health = (presetId: string) => + googleCatalog.find((preset) => preset.id === presetId)?.healthCheck; + + expect(health("google-meet")).toEqual({ + operation: "meet.conferenceRecords.list", + }); + expect(health("google-tasks")).toEqual({ + operation: "tasks.tasklists.list", + args: { maxResults: 1 }, + }); + expect(health("google-admin-reports")).toEqual({ + operation: "reports.activities.list", + args: { userKey: "all", applicationName: "login", maxResults: 1 }, + }); + expect(health("google-cloud-resource-manager")).toEqual({ + operation: "cloudresourcemanager.projects.search", + args: { pageSize: 1 }, + }); +}); diff --git a/packages/plugins/openapi/src/providers/google/presets.ts b/packages/plugins/openapi/src/providers/google/presets.ts index db2b9bf57a..9591ae86b3 100644 --- a/packages/plugins/openapi/src/providers/google/presets.ts +++ b/packages/plugins/openapi/src/providers/google/presets.ts @@ -1,7 +1,10 @@ import { normalizeGoogleDiscoveryUrl } from "./discovery"; import { compactGoogleOAuthScopes } from "./oauth-scopes"; +import { googleOAuthConsentScopesForPreset } from "./service-policy"; import type { HealthCheckSpec, IntegrationPreset } from "@executor-js/sdk/core"; +export { googleOAuthConsentScopes, googleOAuthConsentScopesForPreset } from "./service-policy"; + export interface GooglePreset { readonly id: string; readonly name: string; @@ -155,12 +158,12 @@ export const googleOpenApiPresets: readonly GoogleOpenApiPreset[] = [ summary: "Spaces, messages, members, reactions, and chat workflows.", url: gd("chat", "v1"), icon: "https://fonts.gstatic.com/s/i/productlogos/chat_2020q4/v8/192px.svg", - oauthAudience: "workspace-admin", + oauthAudience: "advanced-user", }, { id: "google-keep", name: "Google Keep", - summary: "Notes, lists, attachments, and annotations.", + summary: "Create, list, delete, and share notes; download attachments.", url: "https://keep.googleapis.com/$discovery/rest?version=v1", icon: "https://fonts.gstatic.com/s/i/productlogos/keep_2020q4/v8/192px.svg", oauthAudience: "unsupported-user", @@ -208,7 +211,7 @@ export const googleOpenApiPresets: readonly GoogleOpenApiPreset[] = [ { id: "google-apps-script", name: "Google Apps Script", - summary: "Projects, deployments, and script execution.", + summary: "Projects, deployments, versions, processes, and metrics.", url: gd("script", "v1"), icon: "https://fonts.gstatic.com/s/i/productlogos/apps_script/v10/192px.svg", oauthAudience: "advanced-user", @@ -257,52 +260,6 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = // `auth.oauth2.scopes`. // --------------------------------------------------------------------------- -export const googleOAuthConsentScopes: Readonly> = { - "google-calendar": ["https://www.googleapis.com/auth/calendar"], - "google-meet": [ - "https://www.googleapis.com/auth/meetings.space.created", - "https://www.googleapis.com/auth/meetings.space.readonly", - "https://www.googleapis.com/auth/meetings.space.settings", - ], - "google-gmail": [ - "https://mail.google.com/", - "https://www.googleapis.com/auth/gmail.settings.basic", - ], - "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"], - "google-drive": ["https://www.googleapis.com/auth/drive"], - "google-docs": ["https://www.googleapis.com/auth/documents"], - "google-slides": ["https://www.googleapis.com/auth/presentations"], - "google-forms": [ - "https://www.googleapis.com/auth/forms.body", - "https://www.googleapis.com/auth/forms.responses.readonly", - ], - "google-tasks": ["https://www.googleapis.com/auth/tasks"], - "google-people": [ - "https://www.googleapis.com/auth/contacts", - "https://www.googleapis.com/auth/contacts.other.readonly", - "https://www.googleapis.com/auth/directory.readonly", - ], - "google-photos-library": [ - "https://www.googleapis.com/auth/photoslibrary.appendonly", - "https://www.googleapis.com/auth/photoslibrary.edit.appcreateddata", - "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", - ], - "google-photos-picker": ["https://www.googleapis.com/auth/photospicker.mediaitems.readonly"], - "google-chat": ["https://www.googleapis.com/auth/chat.spaces"], - "google-keep": ["https://www.googleapis.com/auth/keep"], - "google-youtube-data": ["https://www.googleapis.com/auth/youtube"], - "google-search-console": ["https://www.googleapis.com/auth/webmasters"], - "google-classroom": ["https://www.googleapis.com/auth/classroom.courses"], - "google-admin-directory": ["https://www.googleapis.com/auth/admin.directory.user"], - "google-admin-reports": ["https://www.googleapis.com/auth/admin.reports.audit.readonly"], - "google-apps-script": ["https://www.googleapis.com/auth/script.projects"], - "google-bigquery": ["https://www.googleapis.com/auth/bigquery"], - "google-cloud-resource-manager": ["https://www.googleapis.com/auth/cloud-platform"], -}; - -export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] => - googleOAuthConsentScopes[presetId] ?? []; - export const googleServiceSlug = (presetId: string): string => presetId.replaceAll("-", "_"); const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; @@ -311,6 +268,7 @@ const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; const GOOGLE_IDENTITY_SCOPES: readonly string[] = ["openid", "email", "profile"]; const GOOGLE_HEALTH_CHECKS: Readonly> = { "google-calendar": { operation: "calendar.calendarList.list" }, + "google-meet": { operation: "meet.conferenceRecords.list" }, "google-gmail": { operation: "gmail.users.labels.list", args: { userId: "me" }, @@ -319,10 +277,17 @@ const GOOGLE_HEALTH_CHECKS: Readonly> = { operation: "drive.about.get", args: { fields: "user" }, }, - "google-tasks": { operation: "tasks.tasklists.list" }, + "google-tasks": { + operation: "tasks.tasklists.list", + args: { maxResults: 1 }, + }, "google-people": { operation: "people.people.get", - args: { resourceName: "people/me", personFields: "emailAddresses" }, + args: { + resourceName: "people/me", + personFields: "emailAddresses", + identityField: "emailAddresses.0.value", + }, }, "google-photos-library": { operation: "photoslibrary.albums.list" }, "google-chat": { operation: "chat.spaces.list" }, @@ -337,9 +302,19 @@ const GOOGLE_HEALTH_CHECKS: Readonly> = { operation: "directory.users.list", args: { customer: "my_customer", maxResults: 1 }, }, + "google-admin-reports": { + operation: "reports.activities.list", + args: { userKey: "all", applicationName: "login", maxResults: 1 }, + }, "google-apps-script": { operation: "script.processes.list" }, - "google-bigquery": { operation: "bigquery.projects.list" }, - "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" }, + "google-bigquery": { + operation: "bigquery.projects.list", + args: { maxResults: 1 }, + }, + "google-cloud-resource-manager": { + operation: "cloudresourcemanager.projects.search", + args: { pageSize: 1 }, + }, }; /** Complete Google OAuth scope set requested by a catalog preset, including @@ -360,19 +335,21 @@ const googleCatalogAuthTemplate = (presetId: string) => [ }, ]; -export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets.map((preset) => ({ - id: preset.id, - name: preset.name, - summary: preset.summary, - ...(preset.url ? { url: preset.url } : {}), - ...(preset.icon ? { icon: preset.icon } : {}), - ...(preset.featured ? { featured: preset.featured } : {}), - family: "google", - specFormat: "google-discovery", - defaultSlug: googleServiceSlug(preset.id), - authTemplate: googleCatalogAuthTemplate(preset.id), - ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}), -})); +export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets + .filter((preset) => preset.oauthAudience !== "unsupported-user") + .map((preset) => ({ + id: preset.id, + name: preset.name, + summary: preset.summary, + ...(preset.url ? { url: preset.url } : {}), + ...(preset.icon ? { icon: preset.icon } : {}), + ...(preset.featured ? { featured: preset.featured } : {}), + family: "google", + specFormat: "google-discovery", + defaultSlug: googleServiceSlug(preset.id), + authTemplate: googleCatalogAuthTemplate(preset.id), + ...(GOOGLE_HEALTH_CHECKS[preset.id] ? { healthCheck: GOOGLE_HEALTH_CHECKS[preset.id] } : {}), + })); // --------------------------------------------------------------------------- // Resolve a stored/normalized Discovery URL back to its preset, so a bundled diff --git a/packages/plugins/openapi/src/providers/google/service-policy.test.ts b/packages/plugins/openapi/src/providers/google/service-policy.test.ts new file mode 100644 index 0000000000..e93feb9c97 --- /dev/null +++ b/packages/plugins/openapi/src/providers/google/service-policy.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from "@effect/vitest"; + +import { googleDiscoveryPolicyFor, isGoogleDiscoveryMethodAllowed } from "./service-policy"; + +const allowed = (service: string, version: string, methodId: string): boolean => + isGoogleDiscoveryMethodAllowed(googleDiscoveryPolicyFor(service, version), methodId); + +it("separates methods that require a different Google credential or product mode", () => { + expect(allowed("gmail", "v1", "gmail.users.settings.delegates.list")).toBe(false); + expect(allowed("calendar", "v3", "calendar.calendars.transferOwnership")).toBe(false); + expect(allowed("drive", "v3", "drive.apps.list")).toBe(false); + expect(allowed("chat", "v1", "chat.spaces.completeImport")).toBe(false); + expect(allowed("chat", "v1", "chat.spaces.messages.attachments.get")).toBe(false); + expect(allowed("classroom", "v1", "classroom.courses.teachers.create")).toBe(false); + expect(allowed("classroom", "v1", "classroom.courses.courseWork.addOnAttachments.create")).toBe( + false, + ); + expect(allowed("youtube", "v3", "youtube.thirdPartyLinks.list")).toBe(false); + expect(allowed("admin", "directory_v1", "directory.chromeosdevices.action")).toBe(false); + expect(allowed("script", "v1", "script.scripts.run")).toBe(false); +}); + +it("removes retired Google Photos sharing methods while retaining supported actions", () => { + expect(allowed("photoslibrary", "v1", "photoslibrary.sharedAlbums.list")).toBe(false); + expect(allowed("photoslibrary", "v1", "photoslibrary.albums.share")).toBe(false); + expect(allowed("photoslibrary", "v1", "photoslibrary.mediaItems.batchCreate")).toBe(true); + expect(allowed("photoslibrary", "v1", "photoslibrary.albums.batchRemoveMediaItems")).toBe(true); +}); + +it("does not infer ordinary user OAuth for Google Keep", () => { + expect(googleDiscoveryPolicyFor("keep", "v1")?.consentScopes).toEqual([]); +}); diff --git a/packages/plugins/openapi/src/providers/google/service-policy.ts b/packages/plugins/openapi/src/providers/google/service-policy.ts new file mode 100644 index 0000000000..3fe466c02e --- /dev/null +++ b/packages/plugins/openapi/src/providers/google/service-policy.ts @@ -0,0 +1,257 @@ +export type GoogleDiscoveryServicePolicy = { + readonly presetId: string; + /** Preferred user-consent scopes. Discovery lists alternatives, not a request bundle. */ + readonly consentScopes: readonly string[]; + readonly authoritativeScopes?: Readonly>; + readonly fallbackMethodScopes?: readonly string[]; + readonly blockedMethodIds?: ReadonlySet; + readonly blockedMethodPrefixes?: readonly string[]; + readonly blockedMethodSubstrings?: readonly string[]; + readonly hiddenParameters?: ReadonlySet; + readonly requiredParameters?: Readonly>>; + readonly requiredRequestBodies?: ReadonlySet; + readonly hiddenSchemaProperties?: Readonly>>; + readonly requiredSchemaProperties?: Readonly>>; +}; + +const auth = (scope: string): string => `https://www.googleapis.com/auth/${scope}`; + +const GOOGLE_PHOTOS_LIBRARY_SCOPES = { + [auth("photoslibrary.appendonly")]: "Add photos and videos to Google Photos", + [auth("photoslibrary.edit.appcreateddata")]: "Edit app-created albums and media in Google Photos", + [auth("photoslibrary.readonly.appcreateddata")]: + "Read app-created albums and media in Google Photos", +} as const; + +export const googleOAuthConsentScopes: Readonly> = { + "google-calendar": [auth("calendar")], + "google-meet": [ + auth("meetings.space.created"), + auth("meetings.space.readonly"), + auth("meetings.space.settings"), + ], + "google-gmail": ["https://mail.google.com/", auth("gmail.settings.basic")], + "google-sheets": [auth("spreadsheets"), auth("drive.file")], + "google-drive": [auth("drive")], + "google-docs": [auth("documents")], + "google-slides": [auth("presentations")], + "google-forms": [auth("forms.body"), auth("forms.responses.readonly")], + "google-tasks": [auth("tasks")], + "google-people": [ + auth("contacts"), + auth("contacts.other.readonly"), + auth("directory.readonly"), + auth("user.addresses.read"), + auth("user.birthday.read"), + auth("user.emails.read"), + auth("user.gender.read"), + auth("user.organization.read"), + auth("user.phonenumbers.read"), + ], + "google-photos-library": Object.keys(GOOGLE_PHOTOS_LIBRARY_SCOPES), + "google-photos-picker": [auth("photospicker.mediaitems.readonly")], + "google-chat": [ + auth("chat.spaces"), + auth("chat.memberships"), + auth("chat.messages"), + auth("chat.customemojis"), + auth("chat.delete"), + auth("chat.users.readstate"), + auth("chat.users.spacesettings"), + auth("chat.users.sections"), + auth("chat.users.availability"), + ], + // Keep requires Workspace domain-wide delegation and is not placed in the + // ordinary user-facing catalog. Retain its scope for stored/enterprise data. + "google-keep": [auth("keep")], + "google-youtube-data": [auth("youtube.force-ssl"), auth("youtube.channel-memberships.creator")], + "google-search-console": [auth("webmasters")], + "google-classroom": [ + auth("classroom.announcements"), + auth("classroom.courses"), + auth("classroom.coursework.me"), + auth("classroom.coursework.students"), + auth("classroom.courseworkmaterials"), + auth("classroom.guardianlinks.me.readonly"), + auth("classroom.guardianlinks.students.readonly"), + auth("classroom.profile.emails"), + auth("classroom.profile.photos"), + auth("classroom.rosters"), + auth("classroom.topics"), + ], + "google-admin-directory": [ + auth("admin.chrome.printers"), + auth("admin.directory.customer"), + auth("admin.directory.device.chromeos"), + auth("admin.directory.device.mobile"), + auth("admin.directory.domain"), + auth("admin.directory.group"), + auth("admin.directory.orgunit"), + auth("admin.directory.resource.calendar"), + auth("admin.directory.rolemanagement"), + auth("admin.directory.user"), + auth("admin.directory.user.security"), + auth("admin.directory.userschema"), + ], + "google-admin-reports": [ + auth("admin.reports.audit.readonly"), + auth("admin.reports.usage.readonly"), + ], + "google-apps-script": [ + auth("script.projects"), + auth("script.deployments"), + auth("script.processes"), + auth("script.metrics"), + ], + "google-bigquery": [auth("bigquery")], + "google-cloud-resource-manager": [auth("cloud-platform")], +}; + +export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] => + googleOAuthConsentScopes[presetId] ?? []; + +const policy = ( + presetId: string, + overrides: Omit = {}, +): GoogleDiscoveryServicePolicy => ({ + presetId, + consentScopes: googleOAuthConsentScopesForPreset(presetId), + ...overrides, +}); + +const GOOGLE_DISCOVERY_POLICIES: Readonly> = { + "calendar/v3": policy("google-calendar", { + blockedMethodIds: new Set(["calendar.calendars.transferOwnership"]), + }), + "meet/v2": policy("google-meet"), + "gmail/v1": policy("google-gmail", { + blockedMethodPrefixes: ["gmail.users.settings.delegates."], + }), + "sheets/v4": policy("google-sheets", { + hiddenSchemaProperties: { + Request: new Set([ + "addDataSource", + "updateDataSource", + "refreshDataSource", + "cancelDataSourceRefresh", + ]), + }, + }), + "drive/v3": policy("google-drive", { + blockedMethodIds: new Set(["drive.apps.list"]), + }), + "docs/v1": policy("google-docs"), + "slides/v1": policy("google-slides"), + "forms/v1": policy("google-forms"), + "tasks/v1": policy("google-tasks"), + "people/v1": policy("google-people", { + requiredParameters: { + "people.people.createContact": new Set(["personFields"]), + "people.people.get": new Set(["personFields"]), + "people.people.getBatchGet": new Set(["resourceNames", "personFields"]), + "people.people.connections.list": new Set(["personFields"]), + "people.people.searchContacts": new Set(["query", "readMask"]), + "people.people.updateContact": new Set(["updatePersonFields"]), + "people.people.listDirectoryPeople": new Set(["sources", "readMask"]), + "people.people.searchDirectoryPeople": new Set(["query", "sources", "readMask"]), + "people.otherContacts.list": new Set(["readMask"]), + "people.otherContacts.search": new Set(["query", "readMask"]), + "people.contactGroups.batchGet": new Set(["resourceNames"]), + }, + requiredRequestBodies: new Set([ + "people.people.createContact", + "people.people.batchCreateContacts", + "people.people.batchUpdateContacts", + "people.people.batchDeleteContacts", + "people.people.updateContact", + "people.people.updateContactPhoto", + "people.contactGroups.create", + "people.contactGroups.update", + "people.contactGroups.members.modify", + "people.otherContacts.copyOtherContactToMyContactsGroup", + ]), + requiredSchemaProperties: { + BatchCreateContactsRequest: new Set(["contacts", "readMask"]), + BatchUpdateContactsRequest: new Set(["contacts", "updateMask", "readMask"]), + BatchDeleteContactsRequest: new Set(["resourceNames"]), + UpdateContactPhotoRequest: new Set(["photoBytes"]), + CreateContactGroupRequest: new Set(["contactGroup"]), + UpdateContactGroupRequest: new Set(["contactGroup"]), + CopyOtherContactToMyContactsGroupRequest: new Set(["copyMask"]), + }, + }), + "photoslibrary/v1": policy("google-photos-library", { + authoritativeScopes: GOOGLE_PHOTOS_LIBRARY_SCOPES, + blockedMethodIds: new Set([ + "photoslibrary.albums.share", + "photoslibrary.albums.unshare", + "photoslibrary.sharedAlbums.get", + "photoslibrary.sharedAlbums.join", + "photoslibrary.sharedAlbums.leave", + "photoslibrary.sharedAlbums.list", + ]), + }), + "photospicker/v1": policy("google-photos-picker", { + authoritativeScopes: { + [auth("photospicker.mediaitems.readonly")]: "Read selected Google Photos media", + }, + fallbackMethodScopes: [auth("photospicker.mediaitems.readonly")], + hiddenParameters: new Set(["access_token", "oauth_token", "key"]), + requiredParameters: { + "photospicker.mediaItems.list": new Set(["sessionId"]), + }, + hiddenSchemaProperties: { + PickingConfig: new Set(["showEducationBanner", "showZeroState", "showExpandedAppBar"]), + }, + }), + "chat/v1": policy("google-chat", { + blockedMethodIds: new Set([ + "chat.spaces.completeImport", + "chat.spaces.messages.attachments.get", + ]), + }), + // Keep is intentionally not given an inferred ordinary-user consent set. + "keep/v1": { ...policy("google-keep"), consentScopes: [] }, + "youtube/v3": policy("google-youtube-data", { + blockedMethodIds: new Set(["youtube.abuseReports.insert", "youtube.tests.insert"]), + blockedMethodPrefixes: ["youtube.thirdPartyLinks."], + hiddenParameters: new Set([ + "onBehalfOfContentOwner", + "onBehalfOfContentOwnerChannel", + "managedByMe", + "forContentOwner", + ]), + }), + "searchconsole/v1": policy("google-search-console"), + "classroom/v1": policy("google-classroom", { + blockedMethodIds: new Set(["classroom.courses.teachers.create"]), + blockedMethodPrefixes: ["classroom.courses.studentGroups.", "classroom.courses.posts."], + blockedMethodSubstrings: [".addOnAttachments.", ".getAddOnContext"], + }), + "admin/directory_v1": policy("google-admin-directory", { + blockedMethodIds: new Set(["directory.chromeosdevices.action"]), + }), + "admin/reports_v1": policy("google-admin-reports"), + "script/v1": policy("google-apps-script", { + blockedMethodIds: new Set(["script.scripts.run"]), + }), + "bigquery/v2": policy("google-bigquery"), + "cloudresourcemanager/v3": policy("google-cloud-resource-manager"), +}; + +export const googleDiscoveryPolicyFor = ( + service: string, + version: string, +): GoogleDiscoveryServicePolicy | undefined => GOOGLE_DISCOVERY_POLICIES[`${service}/${version}`]; + +export const isGoogleDiscoveryMethodAllowed = ( + policyValue: GoogleDiscoveryServicePolicy | undefined, + methodId: string, +): boolean => { + if (!policyValue) return true; + if (policyValue.blockedMethodIds?.has(methodId)) return false; + if (policyValue.blockedMethodPrefixes?.some((prefix) => methodId.startsWith(prefix))) + return false; + if (policyValue.blockedMethodSubstrings?.some((part) => methodId.includes(part))) return false; + return true; +}; diff --git a/packages/plugins/provider-service-split/src/planner.test.ts b/packages/plugins/provider-service-split/src/planner.test.ts index 8144e83496..ddad93266b 100644 --- a/packages/plugins/provider-service-split/src/planner.test.ts +++ b/packages/plugins/provider-service-split/src/planner.test.ts @@ -152,7 +152,6 @@ const googleCatalogMethodPrefixFixtures: ReadonlyMap ["google-photos-library", ["photoslibrary.albums.list"]], ["google-photos-picker", ["photospicker.sessions.create"]], ["google-chat", ["chat.spaces.list"]], - ["google-keep", ["keep.notes.list"]], ["google-youtube-data", ["youtube.channels.list"]], ["google-search-console", ["searchconsole.sites.list", "webmasters.sites.list"]], ["google-classroom", ["classroom.courses.list"]],