Skip to content

Commit c88c728

Browse files
committed
fix(search): run live Calendar service search on the crawl scope
PR #8208 widened Google Calendar's domain-wide delegation scopes to include calendar.readonly for every service-account token. Google refuses the whole token exchange when any requested scope is not authorized for the client, so indexed admin-mode Calendar syncs configured for calendar.events.readonly failed every run and were eventually disabled. Live service search only needed the wider scope to read an all-day event's calendar time zone. events.list returns the calendar's timeZone under calendar.events.readonly, so live search now reads it there and runs on the same single scope as indexing; save-time validation therefore covers live sources too. The admin calendar picker impersonates with calendar.calendarlist.readonly and resolves names from the admin's calendar list. When Google refuses a delegated token, the picker, save-time validation, and live search share one message that names the scopes an administrator must authorize, and a delegated 401/403 in service mode no longer tells the member to reconnect their own account.
1 parent be63842 commit c88c728

17 files changed

Lines changed: 307 additions & 109 deletions

File tree

‎apps/docs/content/docs/search/google-calendar.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ Connecting does not ingest events or build permission records. Changes appear ac
1818

1919
```text
2020
https://www.googleapis.com/auth/calendar.events.readonly
21-
https://www.googleapis.com/auth/calendar.readonly
21+
https://www.googleapis.com/auth/calendar.calendarlist.readonly
2222
https://www.googleapis.com/auth/admin.directory.user.readonly
2323
```
2424

25-
`calendar.readonly` is needed for calendar metadata and the CalendarList picker; event-read scope alone is insufficient. See [CalendarList authorization](https://developers.google.com/workspace/calendar/api/v3/reference/calendarList/list).
25+
Search reads events with `calendar.events.readonly`. The calendar picker also needs `calendar.calendarlist.readonly` to list the delegated administrator's calendars. See [CalendarList authorization](https://developers.google.com/workspace/calendar/api/v3/reference/calendarList/list).
2626

2727
3. In **Settings → Sources → Google Calendar**, choose **Service account**, then **Add connection**. Add or select the Google credential and set **Delegated administrator** to an active Workspace administrator with Directory user-read access.
2828
4. Select calendars, users, a date range, and optional event-text and attendee settings. Save and select this connection as the service source.
@@ -44,7 +44,7 @@ Sim does not copy the organizer's richer event details into an attendee's view o
4444

4545
| Problem | Next step |
4646
| --- | --- |
47-
| Calendar picker or metadata read fails | Include `calendar.readonly` in delegation, along with the other scopes above. |
47+
| Calendar picker fails | Include `calendar.calendarlist.readonly` in delegation, along with the other scopes above. |
4848
| A member gets no matches | Check their personal connection, primary Workspace identity, Users selection, calendars, and source date window. |
4949
| Shared calendar is missing | Confirm both the member and delegated source identity can access that calendar ID. |
5050
| Event is outside the configured range | Adjust the service source's range or narrow the user's query to its allowed window. |

‎apps/docs/content/docs/search/google-drive.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ One JSON key can be used for all three service sources. Enable Drive, Gmail, Cal
5353
https://www.googleapis.com/auth/drive.readonly
5454
https://www.googleapis.com/auth/gmail.readonly
5555
https://www.googleapis.com/auth/calendar.events.readonly
56-
https://www.googleapis.com/auth/calendar.readonly
56+
https://www.googleapis.com/auth/calendar.calendarlist.readonly
5757
https://www.googleapis.com/auth/admin.directory.user.readonly
5858
https://www.googleapis.com/auth/admin.directory.group.readonly
5959
https://www.googleapis.com/auth/admin.directory.domain.readonly

‎apps/sim/connectors/google-calendar/company-crawl.test.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,7 @@ describe('Google Calendar company crawl', () => {
7474
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
7575
adminCredentialType: 'service_account',
7676
adminServiceAccountScopes: ['https://www.googleapis.com/auth/admin.directory.user.readonly'],
77-
serviceAccountDelegationScopes: [
78-
'https://www.googleapis.com/auth/calendar.events.readonly',
79-
'https://www.googleapis.com/auth/calendar.readonly',
80-
],
77+
serviceAccountDelegationScopes: ['https://www.googleapis.com/auth/calendar.events.readonly'],
8178
serviceAccountSubjectFieldId: 'adminEmail',
8279
})
8380
expect(

‎apps/sim/connectors/google-calendar/meta.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@ export const googleCalendarConnectorMeta: ConnectorMeta = {
1919
adminCredentialType: 'service_account',
2020
serviceAccountScopes: ['https://www.googleapis.com/auth/calendar.events.readonly'],
2121
adminServiceAccountScopes: ['https://www.googleapis.com/auth/admin.directory.user.readonly'],
22-
serviceAccountDelegationScopes: [
23-
'https://www.googleapis.com/auth/calendar.events.readonly',
24-
'https://www.googleapis.com/auth/calendar.readonly',
25-
],
22+
serviceAccountDelegationScopes: ['https://www.googleapis.com/auth/calendar.events.readonly'],
2623
serviceAccountSubjectFieldId: 'adminEmail',
2724
},
2825

‎apps/sim/lib/knowledge/application/connectors.test.ts‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,6 @@ vi.mock('@/lib/credentials/application/organization-credentials', () => ({
112112
}))
113113

114114
vi.mock('@/lib/oauth/credential-service', () => ({
115-
ServiceAccountTokenError: class extends Error {
116-
constructor(
117-
readonly statusCode: number,
118-
readonly errorDescription: string,
119-
readonly errorCode?: string
120-
) {
121-
super(errorDescription)
122-
}
123-
},
124115
resolveCredentialTokenBundle: mocks.resolveTokenBundle,
125116
resolveOAuthAccountId: vi.fn(async () => null),
126117
getServiceAccountToken: vi.fn(),
@@ -185,12 +176,9 @@ import {
185176
import type { ConnectorAccessToken } from '@/lib/knowledge/connectors/access-token'
186177
import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_SEARCH_LENGTH } from '@/lib/knowledge/constants'
187178
import { classifyKnowledgeFailure } from '@/lib/knowledge/orchestration/shared'
188-
import {
189-
getServiceAccountToken,
190-
resolveOAuthAccountId,
191-
ServiceAccountTokenError,
192-
} from '@/lib/oauth/credential-service'
179+
import { getServiceAccountToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service'
193180
import * as githubInstallation from '@/lib/oauth/github-installation'
181+
import { ServiceAccountTokenError } from '@/lib/oauth/service-account-token-error'
194182
import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions'
195183
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
196184
import { confluenceConnectorMeta } from '@/connectors/confluence/meta'

‎apps/sim/lib/knowledge/application/connectors.ts‎

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ import { requireOrganizationSearchApproval } from '@/lib/knowledge/search/integr
113113
import { escapeLikePattern } from '@/lib/knowledge/tags/utils'
114114
import { isMemberSyncStatus } from '@/lib/knowledge/types'
115115
import { credentialProviderMatchesService, type ServiceProviderIdentity } from '@/lib/oauth'
116-
import { ServiceAccountTokenError } from '@/lib/oauth/credential-service'
116+
import { describeServiceAccountTokenError } from '@/lib/oauth/service-account-token-error'
117117
import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities'
118118
import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server'
119119
import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server'
@@ -341,30 +341,8 @@ export async function resolveConnectorCredentialAccessToken(input: {
341341

342342
/** Exposes actionable credential refusals without returning raw provider payloads. */
343343
function rethrowConnectorCredentialError(error: unknown): never {
344-
if (error instanceof ServiceAccountTokenError && [400, 401, 403].includes(error.statusCode)) {
345-
let message: string
346-
switch (error.errorCode) {
347-
case 'unauthorized_client':
348-
message =
349-
"Google rejected service-account authorization (unauthorized_client). In Google Admin, authorize the JSON key's numeric client ID with the exact domain-wide delegation scopes in this connector's service-account setup section. Verify the delegated user's Workspace email and allow time for recent delegation changes to propagate."
350-
break
351-
case 'invalid_grant':
352-
message =
353-
"Google rejected the service-account grant (invalid_grant). Check that the JSON key is valid and the delegated user's primary Workspace email is correct."
354-
break
355-
case 'invalid_scope':
356-
message =
357-
"Google rejected the service-account scopes (invalid_scope). In Google Admin, authorize the exact domain-wide delegation scopes in this connector's service-account setup section."
358-
break
359-
case 'access_denied':
360-
message =
361-
'Google denied service-account access (access_denied). Ask your Workspace administrator to check API access policies and domain-wide delegation.'
362-
break
363-
default:
364-
throw error
365-
}
366-
throw new OrchestrationError('validation', message)
367-
}
344+
const message = describeServiceAccountTokenError(error)
345+
if (message) throw new OrchestrationError('validation', message)
368346
rethrowGitHubInstallationSourceError(error)
369347
}
370348

‎apps/sim/lib/knowledge/connectors/access-token.test.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -461,10 +461,7 @@ describe.each([
461461
name: 'Google Calendar',
462462
auth: googleCalendarConnectorMeta.auth,
463463
contentScope: 'https://www.googleapis.com/auth/calendar.events.readonly',
464-
delegatedScopes: [
465-
'https://www.googleapis.com/auth/calendar.events.readonly',
466-
'https://www.googleapis.com/auth/calendar.readonly',
467-
],
464+
delegatedScopes: ['https://www.googleapis.com/auth/calendar.events.readonly'],
468465
},
469466
])('$name declared company authentication', ({ auth, contentScope, delegatedScopes }) => {
470467
const directoryScope = 'https://www.googleapis.com/auth/admin.directory.user.readonly'

‎apps/sim/lib/oauth/credential-service.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,11 @@ import {
8383
refreshTokenIfNeeded,
8484
resolveCredentialTokenBundle,
8585
resolveServiceAccountToken,
86-
ServiceAccountTokenError,
8786
} from '@/lib/oauth/credential-service'
8887
import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram'
8988
import { isMicrosoftProvider } from '@/lib/oauth/microsoft'
9089
import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination'
90+
import { ServiceAccountTokenError } from '@/lib/oauth/service-account-token-error'
9191
import { fanOutSlackTokenChain } from '@/lib/oauth/slack'
9292
import { isTerminalRefreshError, markCredentialDead } from '@/lib/oauth/terminal-errors'
9393
import { GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
@@ -825,7 +825,7 @@ describe('Google service-account token minting', () => {
825825
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(RAW_PROVIDER_ERROR)
826826
})
827827

828-
it('keeps token errors private for selectors', async () => {
828+
it('keeps token error details private for selectors while retaining the documented code', async () => {
829829
queueTableRows(credential, [row])
830830
fetchMock.mockResolvedValueOnce(
831831
Response.json(
@@ -839,7 +839,7 @@ describe('Google service-account token minting', () => {
839839
})
840840
).rejects.toMatchObject({
841841
statusCode: 401,
842-
errorCode: undefined,
842+
errorCode: 'unauthorized_client',
843843
errorDescription: 'Token exchange failed: 401',
844844
})
845845
expect(JSON.stringify(mocks.logger.error.mock.calls)).not.toContain(RAW_PROVIDER_ERROR)

‎apps/sim/lib/oauth/credential-service.ts‎

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import { refreshOAuthToken, TOKEN_REFRESH_TIMEOUT_MS } from '@/lib/oauth/oauth'
4040
import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config'
4141
import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination'
42+
import { ServiceAccountTokenError } from '@/lib/oauth/service-account-token-error'
4243
import {
4344
extractSlackTeamId,
4445
fanOutSlackTokenChain,
@@ -87,17 +88,6 @@ function privateCredentialIdentity(namespace: string, value: string): string {
8788
.digest('base64url')
8889
}
8990

90-
export class ServiceAccountTokenError extends Error {
91-
constructor(
92-
public readonly statusCode: number,
93-
public readonly errorDescription: string,
94-
public readonly errorCode?: string
95-
) {
96-
super(errorDescription)
97-
this.name = 'ServiceAccountTokenError'
98-
}
99-
}
100-
10191
interface AccountInsertData {
10292
id: string
10393
userId: string
@@ -305,29 +295,32 @@ export async function getServiceAccountToken(
305295
const errorBody = response.body
306296
let description = `Token exchange failed: ${response.status}`
307297
let errorCode: string | undefined
308-
if (options?.privacyMode !== 'selector') {
309-
try {
310-
const parsed: unknown = JSON.parse(errorBody)
311-
if (typeof parsed === 'object' && parsed !== null) {
312-
if ('error' in parsed && typeof parsed.error === 'string') {
313-
errorCode = parsed.error
314-
}
315-
if (
316-
'error_description' in parsed &&
317-
typeof parsed.error_description === 'string' &&
318-
parsed.error_description.length > 0
319-
) {
320-
const raw = parsed.error_description
321-
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
322-
description = 'Invalid account credentials.'
323-
} else {
324-
description = raw
325-
}
298+
try {
299+
const parsed: unknown = JSON.parse(errorBody)
300+
if (typeof parsed === 'object' && parsed !== null) {
301+
if (
302+
'error' in parsed &&
303+
typeof parsed.error === 'string' &&
304+
(options?.privacyMode !== 'selector' || SA_DIAGNOSTIC_ERROR_CODES.has(parsed.error))
305+
) {
306+
errorCode = parsed.error
307+
}
308+
if (
309+
options?.privacyMode !== 'selector' &&
310+
'error_description' in parsed &&
311+
typeof parsed.error_description === 'string' &&
312+
parsed.error_description.length > 0
313+
) {
314+
const raw = parsed.error_description
315+
if (raw.includes('SignatureException') || raw.includes('Invalid signature')) {
316+
description = 'Invalid account credentials.'
317+
} else {
318+
description = raw
326319
}
327320
}
328-
} catch {
329-
/** Retain the status-based description when Google returns a non-JSON error. */
330321
}
322+
} catch {
323+
/** Retain the status-based description when Google returns a non-JSON error. */
331324
}
332325
logger.error('Service account token exchange failed', {
333326
status: response.status,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/** A refused Google service-account JWT token exchange, with Google's OAuth error code when known. */
2+
export class ServiceAccountTokenError extends Error {
3+
constructor(
4+
public readonly statusCode: number,
5+
public readonly errorDescription: string,
6+
public readonly errorCode?: string
7+
) {
8+
super(errorDescription)
9+
this.name = 'ServiceAccountTokenError'
10+
}
11+
}
12+
13+
/**
14+
* The admin action for a refused Google service-account token exchange, or
15+
* `null` when the error is not one. Names `scopes` as the domain-wide
16+
* delegation scopes to authorize when given; otherwise points at the
17+
* connector's setup section.
18+
*/
19+
export function describeServiceAccountTokenError(
20+
error: unknown,
21+
scopes?: readonly string[]
22+
): string | null {
23+
if (!(error instanceof ServiceAccountTokenError) || ![400, 401, 403].includes(error.statusCode))
24+
return null
25+
const delegationScopes = scopes?.length
26+
? `these domain-wide delegation scopes: ${scopes.join(', ')}`
27+
: "the exact domain-wide delegation scopes in this connector's service-account setup section"
28+
switch (error.errorCode) {
29+
case 'unauthorized_client':
30+
return `Google rejected service-account authorization (unauthorized_client). In Google Admin, authorize the JSON key's numeric client ID with ${delegationScopes}. Verify the delegated user's Workspace email and allow time for recent delegation changes to propagate.`
31+
case 'invalid_grant':
32+
return "Google rejected the service-account grant (invalid_grant). Check that the JSON key is valid and the delegated user's primary Workspace email is correct."
33+
case 'invalid_scope':
34+
return `Google rejected the service-account scopes (invalid_scope). In Google Admin, authorize ${delegationScopes}.`
35+
case 'access_denied':
36+
return 'Google denied service-account access (access_denied). Ask your Workspace administrator to check API access policies and domain-wide delegation.'
37+
default:
38+
return null
39+
}
40+
}

0 commit comments

Comments
 (0)