Skip to content

Commit 9aff510

Browse files
committed
fix(snowflake): run Cortex Analyst SQL with the resolved credential and reopen keyless reconnects as keyless
Cortex Analyst's "run the SQL" went through a nested snowflake_execute_sql call carrying the already-resolved token and host. With authStyle and domain now authoritative, the executor cleared both before the nested call, so it failed for every credential (and a keyless token would have been sent as a PAT). The generated SQL now runs as one guarded statement request built from the parent call's credential-derived host, token, and token type, through the same requestTarget egress the tool transport uses, with redirects refused. The Snowflake connect dialog opened every reconnect on the programmatic access token. The workload-identity prepare call now takes the reconnected credential and reports whether it is keyless (only to an admin of that credential in the same workspace), and the dialog opens on that mode. Docs: the 60-minute wait applies before signing with a new key; removing the old key needs 10 minutes with keyless Snowflake connections.
1 parent 5780b7d commit 9aff510

12 files changed

Lines changed: 329 additions & 37 deletions

File tree

‎apps/docs/content/docs/integrations/aws-credentials.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ To rotate the signing key without failed runs:
6767
1. Set the new key as `WORKLOAD_IDENTITY_PREVIOUS_KEY` and redeploy (including any background workers that run workflows). Sim publishes it but keeps signing with the current key.
6868
2. Wait at least 5 minutes, so every cached copy of the published keys includes the new one. If any keyless [Snowflake connection](/integrations/snowflake-service-account#keyless-service-user) exists, wait 60 minutes instead: Snowflake caches an issuer's keys for up to an hour.
6969
3. Swap them: set the new key as `WORKLOAD_IDENTITY_SIGNING_KEY` and the old key as `WORKLOAD_IDENTITY_PREVIOUS_KEY`, and redeploy everything that runs workflows.
70-
4. Once every instance signs with the new key, wait 5 more minutes (60 with keyless Snowflake connections), then remove `WORKLOAD_IDENTITY_PREVIOUS_KEY`.
70+
4. Once every instance signs with the new key, wait 5 more minutes (10 with keyless Snowflake connections, whose tokens last that long), then remove `WORKLOAD_IDENTITY_PREVIOUS_KEY`.
7171

7272
Without a signing key, the keyless option is unavailable and access keys work as usual.
7373

‎apps/docs/content/docs/integrations/snowflake-service-account.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Snowflake requires each issuer and subject pair to be unique in an account, and
5050

5151
### Self-hosted deployments
5252

53-
Keyless connections need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issuer Snowflake can reach over the public internet. See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments) for the settings. Snowflake caches an issuer's signing keys for up to **60 minutes**, so when rotating the signing key, keep the old key published (`WORKLOAD_IDENTITY_PREVIOUS_KEY`) for at least 60 minutes after the new key is published, and wait another 60 minutes after switching before removing it. Shorter waits cause runs to fail until Snowflake refreshes its cache.
53+
Keyless connections need `WORKLOAD_IDENTITY_SIGNING_KEY` set and an `https` issuer Snowflake can reach over the public internet. See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments) for the settings. Snowflake caches an issuer's signing keys for up to **60 minutes**, so when rotating the signing key, publish the new key (`WORKLOAD_IDENTITY_PREVIOUS_KEY`) at least 60 minutes before Sim starts signing with it. A shorter wait fails runs until Snowflake refreshes its cache.
5454

5555
## Use a programmatic access token [#programmatic-access-token]
5656

@@ -149,7 +149,7 @@ A token's expiry is fixed at creation. To rotate, generate a new token on the sa
149149
| --- | --- |
150150
| Keyless: "Snowflake did not accept Sim's token" | No user in this account was created with exactly the issuer and subject from the dialog, the user is not `TYPE = SERVICE`, or a network or authentication policy blocks it. Run `SHOW USER WORKLOAD IDENTITY AUTHENTICATION METHODS FOR USER SIM_WORKFLOWS` to compare. |
151151
| Keyless: error 099706 when creating the user | Another user in the account already has this issuer and subject. Find it with `SHOW USER WORKLOAD IDENTITY AUTHENTICATION METHODS` and remove it, or reconnect with a new subject. |
152-
| Keyless: runs fail for up to an hour after a signing-key rotation | Snowflake still caches the old keys. Keep the previous key published for 60 minutes (see [Self-hosted deployments](#self-hosted-deployments)). |
152+
| Keyless: runs fail for up to an hour after a signing-key rotation | Snowflake had not yet seen the new key. Publish a new key at least 60 minutes before signing with it (see [Self-hosted deployments](#self-hosted-deployments)). |
153153
| Keyless option missing | The deployment has no workload-identity signing key, or the connection belongs to an organization rather than a workspace. Use a programmatic access token. |
154154
| Lost token secret | Snowflake shows it only at creation. Generate a replacement and reconnect the credential in Sim. |
155155
| Authentication fails | Check expiry or revocation, the user's network policy and Sim's egress, permitted authentication methods, role restriction, and SQL API access. |

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,14 @@ export function useKeylessSetup({
5151
provider,
5252
sessionKey,
5353
enabled: workspaceScoped,
54+
credentialId,
5455
})
5556
const trust = setup.data?.available ? setup.data : undefined
5657
return {
5758
/** Undefined while this deployment's support is still loading. */
5859
available: workspaceScoped ? setup.data?.available : false,
60+
/** The credential being reconnected is keyless (reported for Snowflake only). */
61+
reconnectsKeyless: Boolean(setup.data?.available && setup.data.reconnectsKeyless),
5962
isReconnect: Boolean(credentialId),
6063
subjectChoice,
6164
setSubjectChoice,

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,17 @@ function SnowflakeAccountForm({
155155
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
156156
const [description, setDescription] = useState(initialDescription ?? '')
157157

158-
// Keyless is the default for a new connection wherever this deployment supports it;
159-
// a reconnect starts on the token, which every existing Snowflake credential uses.
160-
const authType: SnowflakeAuthType =
161-
selectedAuthType ??
162-
(keyless.available && !credentialId ? 'workload_identity' : 'programmatic_access_token')
158+
// A reconnect opens on the credential's current mode, so a keyless connection is never
159+
// turned into a token one by default; a new connection defaults to keyless wherever this
160+
// deployment supports it.
161+
const defaultAuthType: SnowflakeAuthType = credentialId
162+
? keyless.reconnectsKeyless
163+
? 'workload_identity'
164+
: 'programmatic_access_token'
165+
: keyless.available
166+
? 'workload_identity'
167+
: 'programmatic_access_token'
168+
const authType: SnowflakeAuthType = selectedAuthType ?? defaultAuthType
163169
const normalizedHost = normalizeHostInput(host)
164170
const isPending =
165171
keylessSubmit.isPending || createCredential.isPending || updateCredential.isPending

‎apps/sim/hooks/queries/scoped-credentials.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ interface UseWorkloadIdentitySetupProps {
136136
/** One per modal session, so each open gets its own subject. */
137137
sessionKey: string
138138
enabled: boolean
139+
/** The credential being reconnected, if any. */
140+
credentialId?: string
139141
}
140142

141143
/**
@@ -149,7 +151,11 @@ export function useWorkloadIdentitySetup(input: UseWorkloadIdentitySetupProps) {
149151
queryKey: workloadIdentityKeys.setup(input.workspaceId, input.provider, input.sessionKey),
150152
queryFn: async ({ signal }) =>
151153
requestJson(prepareWorkloadIdentityContract, {
152-
body: { workspaceId: input.workspaceId ?? '', provider: input.provider },
154+
body: {
155+
workspaceId: input.workspaceId ?? '',
156+
provider: input.provider,
157+
...(input.credentialId ? { credentialId: input.credentialId } : {}),
158+
},
153159
signal,
154160
}),
155161
enabled: input.enabled && Boolean(input.workspaceId),

‎apps/sim/lib/api/contracts/credentials.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,8 @@ export const prepareWorkloadIdentityContract = defineRouteContract({
561561
body: z.object({
562562
workspaceId: workspaceIdSchema,
563563
provider: z.enum(WORKLOAD_IDENTITY_PROVIDERS),
564+
/** The credential being reconnected, so the dialog opens on its current mode. */
565+
credentialId: z.string().min(1).max(255).optional(),
564566
}),
565567
response: {
566568
mode: 'json',
@@ -578,6 +580,8 @@ export const prepareWorkloadIdentityContract = defineRouteContract({
578580
/** Opaque proof submitted with the credential; binds the subject to its creator. */
579581
subjectProof: z.string(),
580582
expiresAt: z.string(),
583+
/** The reconnected credential is keyless (Snowflake only). */
584+
reconnectsKeyless: z.literal(true).optional(),
581585
}),
582586
]),
583587
},
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { generateKeyPairSync } from 'node:crypto'
5+
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
6+
import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock'
7+
import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock'
8+
import {
9+
workspaceContextMock,
10+
workspaceContextMockFns,
11+
} from '@sim/testing/mocks/workspace-context.mock'
12+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const hoisted = vi.hoisted(() => ({ actorContext: vi.fn() }))
15+
16+
vi.mock('@/lib/workspaces/application/context', () => workspaceContextMock)
17+
vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
18+
vi.mock('@/lib/core/security/encryption', () => encryptionMock)
19+
vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: hoisted.actorContext }))
20+
21+
import { env } from '@/lib/core/config/env'
22+
import { prepareWorkloadIdentity } from '@/lib/credentials/application/prepare-workload-identity'
23+
24+
const principal = createSessionPrincipal()
25+
const keylessSecret = {
26+
type: 'snowflake_workload_identity_v1',
27+
config: {
28+
authType: 'workload_identity',
29+
host: 'myorg-myaccount.snowflakecomputing.com',
30+
subject: 'sim:workspace:workspace-1:credential:abcdef0123456789',
31+
},
32+
}
33+
const tokenSecret = {
34+
type: 'token_service_account',
35+
providerId: 'snowflake-service-account',
36+
apiToken: 'pat-secret',
37+
domain: 'myorg-myaccount.snowflakecomputing.com',
38+
}
39+
40+
/** The credential being reconnected, as the caller's access to it resolves. */
41+
function reconnecting(secret: object, access: { isAdmin?: boolean; workspaceId?: string } = {}) {
42+
hoisted.actorContext.mockResolvedValue({
43+
credential: {
44+
workspaceId: access.workspaceId ?? 'workspace-1',
45+
type: 'service_account',
46+
providerId: 'snowflake-service-account',
47+
encryptedServiceAccountKey: 'ciphertext',
48+
},
49+
member: null,
50+
hasWorkspaceAccess: true,
51+
canWriteWorkspace: true,
52+
isAdmin: access.isAdmin ?? true,
53+
})
54+
encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: JSON.stringify(secret) })
55+
}
56+
57+
function prepare() {
58+
return prepareWorkloadIdentity.execute({
59+
principal,
60+
input: { workspaceId: 'workspace-1', provider: 'snowflake', credentialId: 'cred-1' },
61+
})
62+
}
63+
64+
beforeAll(() => {
65+
Reflect.set(
66+
env,
67+
'WORKLOAD_IDENTITY_SIGNING_KEY',
68+
generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({
69+
type: 'pkcs8',
70+
format: 'pem',
71+
})
72+
)
73+
Reflect.set(env, 'WORKLOAD_IDENTITY_ISSUER', 'https://sim.example.com/oidc/workload')
74+
})
75+
76+
afterAll(() => {
77+
Reflect.set(env, 'WORKLOAD_IDENTITY_SIGNING_KEY', undefined)
78+
Reflect.set(env, 'WORKLOAD_IDENTITY_ISSUER', undefined)
79+
})
80+
81+
describe('preparing a Snowflake reconnect', () => {
82+
beforeEach(() => {
83+
vi.clearAllMocks()
84+
workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({
85+
workspaceId: 'workspace-1',
86+
workspaceOrganizationId: null,
87+
allowPersonalApiKeys: true,
88+
billedAccountUserId: 'user-1',
89+
})
90+
workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('write')
91+
})
92+
93+
it('opens a keyless credential on keyless', async () => {
94+
reconnecting(keylessSecret)
95+
await expect(prepare()).resolves.toMatchObject({ reconnectsKeyless: true })
96+
})
97+
98+
it('opens a token credential on the token', async () => {
99+
reconnecting(tokenSecret)
100+
expect(await prepare()).not.toHaveProperty('reconnectsKeyless')
101+
})
102+
103+
it('reveals nothing about a credential the caller does not administer', async () => {
104+
reconnecting(keylessSecret, { isAdmin: false })
105+
expect(await prepare()).not.toHaveProperty('reconnectsKeyless')
106+
expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled()
107+
})
108+
109+
it('reveals nothing about a credential from another workspace', async () => {
110+
reconnecting(keylessSecret, { workspaceId: 'workspace-2' })
111+
expect(await prepare()).not.toHaveProperty('reconnectsKeyless')
112+
expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled()
113+
})
114+
})

‎apps/sim/lib/credentials/application/prepare-workload-identity.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
22
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
33
import { OrchestrationError } from '@/lib/core/orchestration/types'
4+
import { decryptSecret } from '@/lib/core/security/encryption'
5+
import { getCredentialActorContext } from '@/lib/credentials/access'
46
import { credentialOperations } from '@/lib/credentials/application/operations'
7+
import { isKeylessSnowflakeSecret } from '@/lib/credentials/snowflake-service-account.server'
8+
import { SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors'
59
import {
610
WORKLOAD_IDENTITY_AUDIENCES,
711
type WorkloadIdentityProvider,
@@ -16,6 +20,8 @@ import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/applicat
1620
export interface PrepareWorkloadIdentityInput {
1721
workspaceId: string
1822
provider: WorkloadIdentityProvider
23+
/** The Snowflake credential being reconnected, so the dialog can open on its current mode. */
24+
credentialId?: string
1925
}
2026

2127
export type PrepareWorkloadIdentityResult =
@@ -27,8 +33,36 @@ export type PrepareWorkloadIdentityResult =
2733
subject: string
2834
subjectProof: string
2935
expiresAt: string
36+
/** The reconnected credential is keyless; absent when there is none or it is not. */
37+
reconnectsKeyless?: true
3038
}
3139

40+
/**
41+
* Whether a Snowflake credential in this workspace, which this person
42+
* administers, is keyless. Only that one bit leaves the stored secret, and only
43+
* for someone allowed to reconnect the credential.
44+
*/
45+
async function isKeylessSnowflakeCredential(
46+
credentialId: string,
47+
workspaceId: string,
48+
userId: string
49+
): Promise<boolean> {
50+
const access = await getCredentialActorContext(credentialId, userId, { workspaceId })
51+
const row = access.credential
52+
if (
53+
!access.isAdmin ||
54+
!row ||
55+
row.workspaceId !== workspaceId ||
56+
row.type !== 'service_account' ||
57+
row.providerId !== SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID ||
58+
!row.encryptedServiceAccountKey
59+
) {
60+
return false
61+
}
62+
const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
63+
return isKeylessSnowflakeSecret(decrypted)
64+
}
65+
3266
/**
3367
* Starts a keyless cloud connection (an AWS role, a Databricks service
3468
* principal, or a Snowflake service user): the subject the customer's trust
@@ -47,16 +81,22 @@ export const prepareWorkloadIdentity = defineAuthorizedWorkspaceUseCase({
4781
execute: async ({ principal, input }): Promise<PrepareWorkloadIdentityResult> => {
4882
// A deployment without a signing key offers only key- and secret-based credentials.
4983
if (!(await isWorkloadIdentityConfigured())) return { available: false }
84+
const userId = requirePrincipalSubjectUserId(principal)
5085
const grant = issueWorkloadIdentitySubject(
5186
input.provider,
5287
{ workspaceId: input.workspaceId },
53-
requirePrincipalSubjectUserId(principal)
88+
userId
5489
)
90+
const reconnectsKeyless =
91+
input.provider === 'snowflake' && input.credentialId
92+
? await isKeylessSnowflakeCredential(input.credentialId, input.workspaceId, userId)
93+
: false
5594
return {
5695
available: true,
5796
issuer: getWorkloadIdentityIssuer(),
5897
audience: WORKLOAD_IDENTITY_AUDIENCES[input.provider],
5998
...grant,
99+
...(reconnectsKeyless ? { reconnectsKeyless: true as const } : {}),
60100
}
61101
},
62102
})

‎apps/sim/lib/credentials/snowflake-service-account.server.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,15 @@ export class SnowflakeCredentialError extends Error {
102102
}
103103
}
104104

105+
/** Whether a decrypted Snowflake secret is a keyless configuration rather than a token. */
106+
export function isKeylessSnowflakeSecret(decrypted: string): boolean {
107+
try {
108+
return storedSecretSchema.safeParse(JSON.parse(decrypted)).success
109+
} catch {
110+
return false
111+
}
112+
}
113+
105114
function readStoredSubject(storedSecret: Record<string, unknown> | null | undefined) {
106115
const parsed = storedSecretSchema.safeParse(storedSecret)
107116
return parsed.success ? parsed.data.config.subject : undefined

0 commit comments

Comments
 (0)