Skip to content

Commit b97449a

Browse files
authored
fix(credentials): replay remembered refusals as credential errors, derive the delegable Google services list, and correct a fallback TSDoc (#9993)
1 parent 990ea3a commit b97449a

7 files changed

Lines changed: 58 additions & 15 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
getDatabaseConnectionDescriptor,
3232
} from '@/lib/credentials/database-connections/descriptors'
3333
import {
34+
GOOGLE_DELEGATION_SERVICE_NAMES,
3435
googleDelegationServiceFor,
3536
googleKeylessOffer,
3637
isGoogleKeylessService,
@@ -61,7 +62,6 @@ import { DatabricksAccountModal } from '@/app/workspace/[workspaceId]/integratio
6162
import {
6263
buildGoogleKeylessConfig,
6364
emptyGoogleKeylessInput,
64-
GOOGLE_DELEGATION_SERVICE_NAMES,
6565
GoogleKeylessFields,
6666
type GoogleKeylessInput,
6767
isGoogleKeylessInputComplete,

‎apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/google-keyless-fields.tsx‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,6 @@ const DELEGATION_OPTIONS = GOOGLE_DELEGATION_SERVICES.map((service) => ({
5656
label: getServiceConfigByServiceId(service)?.name ?? service,
5757
}))
5858

59-
/** "Gmail, Google Drive, …, and Google Calendar": the services keyless credentials can delegate. */
60-
export const GOOGLE_DELEGATION_SERVICE_NAMES = new Intl.ListFormat('en', {
61-
style: 'long',
62-
type: 'conjunction',
63-
}).format(DELEGATION_OPTIONS.map((option) => option.label))
64-
6559
/** Whether the provider and service account fields have Google's documented format. */
6660
function isTrustComplete(value: GoogleKeylessInput): boolean {
6761
return (

‎apps/sim/executor/utils/model-fallback-request.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ interface ModelFallbackRequestInput {
2929
fallbackSystemPrompt: string
3030
retry?: BlockRetryAttempt
3131
resolvedSecretTraceRegistry: ResolvedSecretTraceRegistry | undefined
32-
/** The block's saved Vertex or Bedrock credential selection, resolved per attempt. */
32+
/**
33+
* The block's saved Vertex or Bedrock credential selection, resolved once per call inside the
34+
* first attempt on the primary's provider and shared by the fallbacks on that provider.
35+
*/
3336
credentials?: { inputs: BlockModelCredentialInputs; callerLabel: string }
3437
}
3538

‎apps/sim/lib/credentials/coordinated-client-token.integration.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,24 @@ describe('coordinated token resolution while the provider fails', () => {
391391
expect(exchange).toHaveBeenCalledTimes(2)
392392
})
393393

394+
it('replays a refusal the shared row cannot store as a credential to fix, never as an outage', async () => {
395+
const exchange = vi.fn(async (): Promise<{ accessToken: string; expiresAt: Date }> => {
396+
throw new TokenServiceAccountValidationError('consent_required', 400, { step: 'mint' })
397+
})
398+
await expect(
399+
resolveCoordinatedClientToken(input('consent-replayed', exchange))
400+
).rejects.toMatchObject({ code: 'consent_required' })
401+
402+
await expect(
403+
resolveCoordinatedClientToken(input('consent-replayed', exchange))
404+
).rejects.toMatchObject({
405+
code: 'invalid_credentials',
406+
status: 400,
407+
replayForMs: expect.any(Number),
408+
})
409+
expect(exchange).toHaveBeenCalledOnce()
410+
})
411+
394412
it('never serves a still-valid token to a different secret whose refresh fails', async () => {
395413
const exchange = issuingOnce('rotated-refresh', 4 * 60_000, failing(429))
396414
await resolveCoordinatedClientToken(input('rotated-refresh', exchange))
@@ -433,7 +451,11 @@ describe('coordinated token resolution while the provider fails', () => {
433451
).rejects.toMatchObject({ code: 'provider_unavailable', status: 429 })
434452
await expect(
435453
resolveCoordinatedClientToken(input(application, exchange))
436-
).rejects.toMatchObject({ status: 429, replayForMs: expect.any(Number) })
454+
).rejects.toMatchObject({
455+
code: 'provider_unavailable',
456+
status: 429,
457+
replayForMs: expect.any(Number),
458+
})
437459

438460
expect(exchange).toHaveBeenCalledOnce()
439461
const stored = await failureRowOf(application)
@@ -644,7 +666,7 @@ describe('default token rows shared with the previous build during a rolling dep
644666
const { exchange } = provider(application)
645667

646668
await expect(resolveCoordinatedClientToken(input(application, exchange))).rejects.toMatchObject(
647-
{ code: 'provider_unavailable', status: 400 }
669+
{ code: 'invalid_credentials', status: 400 }
648670
)
649671
expect(exchange).not.toHaveBeenCalled()
650672
})

‎apps/sim/lib/credentials/coordinated-client-token.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,14 +231,22 @@ function isRememberedRefusal(failure: StoredFailure): boolean {
231231
)
232232
}
233233

234-
/** The error a remembered failure is replayed as, with how long it is still replayed. */
234+
/**
235+
* The error a remembered failure is replayed as, with how long it is still replayed. A refusal
236+
* stored as `provider_unavailable` because the memo cannot hold its own code is replayed as
237+
* `invalid_credentials`, so it still asks for the credential to be fixed rather than waited out.
238+
*/
235239
function replayedFailure(
236240
failure: StoredFailure,
237241
step: string,
238242
replayMs: number
239243
): TokenServiceAccountValidationError {
244+
const code =
245+
failure.code === 'provider_unavailable' && isRememberedRefusal(failure)
246+
? 'invalid_credentials'
247+
: failure.code
240248
return new TokenServiceAccountValidationError(
241-
failure.code,
249+
code,
242250
failure.status,
243251
{ step },
244252
{ replayForMs: Math.max(replayMs, 0) }

‎apps/sim/lib/credentials/google-workload-identity.server.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { z } from 'zod'
55
import { coalesceLocally } from '@/lib/concurrency/singleflight'
66
import { encryptSecret } from '@/lib/core/security/encryption'
77
import {
8+
GOOGLE_DELEGATION_SERVICE_NAMES,
89
GOOGLE_DELEGATION_SERVICES,
910
GOOGLE_KEYLESS_SERVICES,
1011
GOOGLE_WORKLOAD_IDENTITY_SECRET_TYPE,
@@ -964,12 +965,12 @@ export async function resolveGoogleWorkloadIdentityToken(
964965
? unsupportedUse(
965966
'google_keyless_service',
966967
'this keyless Google credential works only with Google Cloud APIs and the Workspace services it was delegated',
967-
'This keyless Google credential works only with Google Cloud tools such as BigQuery and, through domain-wide delegation, with Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar. Reconnect it to delegate this service, or use a JSON key credential instead'
968+
`This keyless Google credential works only with Google Cloud tools such as BigQuery and, through domain-wide delegation, with ${GOOGLE_DELEGATION_SERVICE_NAMES}. Reconnect it to delegate this service, or use a JSON key credential instead`
968969
)
969970
: unsupportedUse(
970971
'google_keyless_service_unsupported',
971972
'keyless Google credentials cannot serve this service',
972-
'Keyless Google credentials work only with Google Cloud tools such as BigQuery and, through domain-wide delegation, with Gmail, Google Drive, Google Docs, Google Sheets, and Google Calendar. Use a JSON key credential for this service'
973+
`Keyless Google credentials work only with Google Cloud tools such as BigQuery and, through domain-wide delegation, with ${GOOGLE_DELEGATION_SERVICE_NAMES}. Use a JSON key credential for this service`
973974
)
974975
}
975976
}

‎apps/sim/lib/credentials/google-workload-identity.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818

1919
import { impersonateEmailSchema } from '@/lib/api/contracts/oauth-connections'
2020
import { storedAudienceField } from '@/lib/credentials/workload-identity/audiences'
21-
import { getProviderIdFromServiceId, getScopesForService } from '@/lib/oauth/utils'
21+
import {
22+
getProviderIdFromServiceId,
23+
getScopesForService,
24+
getServiceConfigByServiceId,
25+
} from '@/lib/oauth/utils'
2226

2327
/**
2428
* The Google services a keyless credential may mint for as the service account
@@ -44,6 +48,17 @@ export const GOOGLE_DELEGATION_SERVICES = [
4448

4549
export type GoogleDelegationService = (typeof GOOGLE_DELEGATION_SERVICES)[number]
4650

51+
/**
52+
* "Gmail, Google Drive, …, and Google Calendar": the services a keyless
53+
* credential can delegate, by the names the delegation picker shows.
54+
*/
55+
export const GOOGLE_DELEGATION_SERVICE_NAMES = new Intl.ListFormat('en', {
56+
style: 'long',
57+
type: 'conjunction',
58+
}).format(
59+
GOOGLE_DELEGATION_SERVICES.map((service) => getServiceConfigByServiceId(service)?.name ?? service)
60+
)
61+
4762
const DELEGATION_SERVICE_SET: ReadonlySet<string> = new Set(GOOGLE_DELEGATION_SERVICES)
4863

4964
function isGoogleDelegationService(value: string): value is GoogleDelegationService {

0 commit comments

Comments
 (0)